【モバイルアプリ】テスタビリティ向上にむけ暗号資産購入画面を再設計しました

 Mobileグループ 竹内と申します。主にモバイルアプリの設計とAndroidの実装を担当しています。

 弊社販売所モバイルアプリ(以下、アプリ)の暗号資産購入画面(以下、購入画面)を、少し前にリファクタリングしました。本記事ではそのプロセスを、実装例とともにご紹介します1

  • コードを本記事中に埋め込んでおります。量はやや多めですが、読み飛ばしていただいても本記事の趣旨は把握いただけると思います。
  • 想定した読者像は「一般的なモバイルアプリのアーキテクチャ・設計・テスト手法について書籍などでひととおり学習したが、それらがどのように取り入れられているのか実例を知りたい方」になります。

アプリのアーキテクチャ概要

 アプリのアーキテクチャはMVVM + VIPERで、ReactiveXを全面的に採用しております。より詳しく知りたい場合は次の記事を参照ください。 tech.coincheck.blog

購入画面の概要

 購入画面はシンプルで無駄のないUIです。一方、仕様や内部ロジックは複雑で、ともすればストリームも入りみだれFAT ViewModelになりがちでした。上述したリニューアルによりアプリ全体の責務が分割されUnitTest可能な範囲も広がりましたが、購入画面のテスタビリティは高くありませんでした。

購入画面

 購入画面のリファクタリングによりテスタビリティが向上し、コードの意図もより明確になりました。以降「課題」「解決方法とその実装」「結果」の順に解説します。

課題

 特に次の点が課題となっていました。

コードから一連の動作を把握するコストが高い

  • コンポーネントがクラス化されておらず、ViewおよびViewModelレイヤーのストリームにそれらの加工・判断・計算ロジックが直接記述されている箇所が多い
  • 複数のストリームが頻繁に結合される。「今このストリームに流れてきた値の期待値」をコードから読み取りづらい

 以上のような状態でした(ロジックの具体例:入力フォームへ購入金額が入力されたときは、金額として正しいフォーマットに自動整形し、その際入力額が残高を超過した場合はさらに残高に丸める。ただし暗号資産を日本円で購入するケースとBTCで購入するケースがあり、それぞれで参照する入力金額および残高オブジェクトを切り替える必要がある。同時に通貨レートも更新し、入力金額と掛け合わせ購入される暗号資産の量を算出し表示する、など。これでもすべてではありませんが、これらだけでもなかなか複雑です)。

テストケースが多く、動作確認にコストがかかる

 入力金額の自動整形ロジックだけでも、小数点の有無、入力額の閾値確認、カンマの付与、残高の超過判定、といったものがあります。何か改修が発生するたびにシナリオテストを実施する必要があり、非効率でした。E2Eツールを検討したこともありますが、その導入運用コストが購入画面をはじめとしたUIの改修頻度に見合うのか難しいところがありました。

解決のアプローチ

 なるべくUnitTestで品質担保できる範囲を増やすことを目標に、設計を検討しました。

 UnitTestを書きづらい状態とは、そもそもどういった状態を指すのでしょう? DI(Dependency Injection)されていない状態でしょうか? それも一つの回答ですが、筆者はそれを「多数の問題が一度に扱われている状態」だと考えます。「少なくともこの範囲の内容Aについて、それがBならば常にCである」というふうに問題を分割し整理します。ここでAはクラス・関数・不変条件の表現に、BはAの入力に、CはAの出力に対応します。

 こうして書いてみると当然の話のように思えますが、むずかしいのは「どの粒度で問題を分割すべきかを決めること」だと考えます。経験上、ここは人によって差が出やすいところです。ある機能を誰が設計してもまったく同じ結果になるようなことは、まずないでしょう。そこで「Aに対するUnitTestを書くことができる粒度か」をその判断基準にすれば(テストファーストで実装すべしという話ではありません)、設計者の課題解決アプローチがどのようなものであるか、他メンバーにも理解してもらいやすくなると考えます2。少なくとも、設計者の意図を汲んだレビューがしやすくはなるでしょう。UnitTestの一般的な書き方についてここでは述べませんが、下記を引用しておきます。

 「テストの名前はユーザの視点から見たwhatとwhyを記述しなければならない」 – この考え方は、開発者がテストの名前を読んで、意図されたふるまいがどのようなものであるかを理解できなければならないということだ。——『より良いユニットテストのために』

 次に、金額入力フォームを中心とした設計・実装について解説します。

金額入力フォーム

モデルの設計

 まず、購入画面の仕様のうち、金額入力フォームが担当するものを確認します。次のようになりました。

  • 入力フォームへ購入金額が入力されたとき、金額として正しい文字列フォーマット(= 正しい小数点やカンマ位置)に自動整形する。
  • 入力額が残高を超過した場合、残高に丸める。
  • 暗号資産を日本円で購入するケースとBTCで購入するケースがあり、それぞれで参照する入力金額および残高オブジェクトを切り替える。

 次に、金額入力フォームの仕様を次の3つに分類します3

  1. 購入画面の入力フォーム固有の仕様
  2. アプリ内に存在する入力フォームで共通の仕様
  3. 弊社ドメイン固有でない(業界全体・数値表現一般の)入力フォームの仕様

 続いてこれらのクラス表現を検討します。ここで、2と3を別々のクラスにすべき強い理由はありませんでした。よってクラスを次のとおりとします。

  • 1: BuyForm
  • 2,3: Form
    • ​​関連するクラスとしてinterface Form.EditText, class PriceInputEditTextも作成しました。後述します。

 そして分類された入力フォームの仕様それぞれについてBuyFormFormどちらが責務として適切かを判断し、ロジックを実装します。

 最終的には次のようになりました。

/** 購入画面: 価格入力フォーム(金額テキスト、選択中のレート単位) */
data class BuyForm(
    /** JPY建の入力 */
    private val formJpy: Form = Form.Manual(),
    /** BTC建の入力 */
    private val formBtc: Form = Form.Manual(),
    /** 現在選択中のレート単位 */
    val rateUnit: CurrencyRatesUnit = CurrencyRatesUnit.JPY
) {
    /** 現在の入力額 */
    val current: Form = of(rateUnit)

    /** [rateUnit]の入力額 */
    fun of(rateUnit: CurrencyRatesUnit): Form =
        when (rateUnit) {
            CurrencyRatesUnit.JPY -> formJpy
            CurrencyRatesUnit.BTC -> formBtc
        }

    /** [rateUnit]の選択状態を切り替える */
    fun toggleRateUnit(): BuyForm =
        // 切り替えにより[current]の戻り値も変更されるので、formを[Form.Auto]で作成しなおす
        when (rateUnit) {
            CurrencyRatesUnit.JPY ->
                copy(
                    formBtc = Form.Auto(formBtc.number),
                    rateUnit = CurrencyRatesUnit.BTC
                )
            CurrencyRatesUnit.BTC ->
                copy(
                    formJpy = Form.Auto(formJpy.number),
                    rateUnit = CurrencyRatesUnit.JPY
                )
        }

    /** 現在の入力額を更新したコピーインスタンスを返す */
    fun update(form: Form): BuyForm =
        when (rateUnit) {
            CurrencyRatesUnit.JPY -> copy(formJpy = form)
            CurrencyRatesUnit.BTC -> copy(formBtc = form)
        }

    /** 入力額を残高に丸める */
    fun roundIfExceeds(balance: BuySellBalance): BuyForm {
        val max = balance.of(rateUnit)
        return if (current.number > max.value) {
            update(Form.Auto(max))
        } else {
            this
        }
    }
}

 続いてFormクラス。

/**
 * 価格の入力フォームの文字列
 *
 * @param stringMaybeUnformatted 価格(数値)の文字列表現。カンマの有無は問わない
 */
sealed class Form(stringMaybeUnformatted: String = "") {
    /**
     * 最終的に入力フォームにsetTextされる価格(数値)の文字列表現
     * - カンマが正しい位置に付与されている
     * - 入力中の ".", "0.", "0.0", "0.00" などは整形されずそのまま
     * - "¥ ", " BTC", " ETH"などの単位はつかない
     */
    val stringFormatted: String =
        stringMaybeUnformatted.format() // Formatロジックは割愛

    /** 演算用の数値 */
    val number: DecimalNumber =
        stringMaybeUnformatted.toDecimalNumber()

    /** システムで決定し、入力フォームに自動反映する価格(数値)の文字列表現 */
    class Auto(maybeUnformatted: String = "") : Form(maybeUnformatted) {
        // 割愛
    }

    /** ユーザーが手動で入力し、確定した価格(数値)の文字列表現 */
    class Manual(maybeUnformatted: String = "") : Form(maybeUnformatted) {
        companion object {
            /** ユーザーの入力した価格の文字列を検証、加工して確定する */
            fun confirm(inputText: String): Manual? {
                // 加工ロジックは割愛
            }
        }
    }

    // (以下略)

 Formクラスは複雑ですが、設計意図は次のとおりです。

  • 「ユーザーによる手動入力結果」「システムによる自動入力結果」をそれぞれ異なる状態とみなし、別のクラスで表現した。
  • 入力された値(String型)およびその加工ロジックをクラスに隠蔽し、ストリームのどの時点であっても入力内容の期待値を把握しやすくした。

View周辺のロジックを切り出す

 続いてAndroid OSのViewクラスであるandroid.widget.EditTextもテスタビリティ向上を目指します。テキスト入力フォームに用いるお馴染みのクラスですが、得てしてイベントのハンドリングは複雑になりがちです。そのため、これらイベント周辺ロジックを可能な限り切り出しました。

// (このinterface EditTextは、Formクラスのnested classにしています)

/**
 * 入力内容のフォーマット・バリデーションロジックを持つ。
 * 画面個別の仕様(たとえば、「購入画面の入力可能な最大値=残高」で入力値を丸める)は持たない。
 *
 * TextInputEditTextなどのEditTextに、このI/Fをimplementsしてください。
 * フォーム入力値のUnitTestのため、ここにロジックを分離しています。
 */
interface EditText {

    /** 手動入力によりテキストが変更されたとき発火する */
    val formUpdatedManuallyStream: BehaviorRelay<Manual>

    /**
     * システムが自動で決定した内容をフォームにsetTextする。
     */
    fun setTextAuto(form: Auto) {
        previous = form
        setTextSilently(form.stringFormatted)
    }

    //
    // 以下をEditTextの実装クラスでoverrideする
    //

    fun setText(string: CharSequence)

    fun setSelection(position: Int)

    // (EditTextのコンストラクタでaddTextChangedListenerして、そこからこれをコールしてください)
    fun onTextChanged(input: String) {
        if (preventsCallbackTwice) {
            preventsCallbackTwice = false
            return
        }

        // 入力内容の検証、確定する
        val confirmed = Manual.confirm(input)

        // 検証に失敗したら直前の入力内容に戻す
        if (confirmed == null) {
            setTextSilently(previous.stringFormatted)
            return
        }

        // 検証に成功したら入力として確定する
        setTextSilently(confirmed.stringFormatted)

        formUpdatedManuallyStream.accept(confirmed)
    }

    /**
     * 直前の入力内容のキャッシュ。
     *
     * interface EditText自身が持つロジックでしか参照しないこと。
     */
    var previous: Form

    /**
     * true: addTextChangedListenerを回避する。
     *
     * interface EditText自身が持つロジックでしか参照しないようにすること。
     * implementするクラスでは、初期値をfalseで指定すること(trueでの初期化は想定していない)。
     */
    var preventsCallbackTwice: Boolean

    // setTextしつつ、addTextChangedListenerで即returnする
    private fun setTextSilently(stringFormatted: String) {
        preventsCallbackTwice = true
        setText(stringFormatted)
        setSelection(stringFormatted.length)  // カーソルは常に右にする
    }
}

 こちらもかなり複雑です。var preventsCallbackTwice: Booleanがやや苦しい気もしますが問題ありません4

 最後にinterface EditTextを実装したPriceInputEditTextクラスです。こちらはほぼoverrideするだけです。

/** 価格の入力フォーム。入力内容のフォーマット・バリデーションを行う。画面個別の仕様は持たない。 */
class PriceInputEditText : TextInputEditText, Form.EditText {
    // constructorの実装は割愛

    init {
        addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            override fun afterTextChanged(s: Editable?) {
                onTextChanged(s.toString())
            }
        })
    }

    override var previous: Form = Form.Auto()

    override var preventsCallbackTwice: Boolean = false

    // このBehaviorRelayインスタンスは、常に当該Fragmentのpropertyとして保持されViewModelにバインドしたインスタンスである必要がある。
    //
    // よって、次のように実装する必要がある。
    // - BehaviorRelayインスタンスは、ここで初期化しない
    // - BehaviorRelayインスタンスを、当該Fragmentのpropertyとして保持する
    // - Viewの生成および再生成によりonCreateViewでfragment_XXX.xmlがinflateされるたび、Fragmentのpropertyを外からセットしなおす
    override lateinit var formUpdatedManuallyStream: BehaviorRelay<Form.Manual>
}

UnitTest

 作成したUnitTestです。Formクラスの加工ロジックのテストと interface EditTextのイベントハンドリングのテストになります。

@RunWith(JUnitParamsRunner::class)
class FormTest {

    // 加工ロジックのテスト
    @Test
    @Parameters(method = "provideTestManualConfirm")
    fun testManualConfirm(testCase: String, expected: String?, given: String) {
        assertEquals(
            message = testCase,
            expected = expected?.let { Form.Manual(it) }?.stringFormatted,
            actual = Form.Manual.confirm(given)?.stringFormatted
        )
    }

    private fun provideTestManualConfirm(): Array<Array<out Any?>> =
        arrayOf(
            // testCase, expected, given
            arrayOf("小数点は1つしか打てない", null, ".."),
            arrayOf("空白なら0になる", "0", ""),
            arrayOf(".でも小数部がフォーマットされない", ".", "."),
            arrayOf(".0でも小数部がフォーマットされない", "0.0", ".0"),
            arrayOf(".00でも小数部がフォーマットされない", "0.00", ".00"),
            arrayOf("0.でも小数部がフォーマットされない", "0.", "0."),
            arrayOf("0.0でも小数部がフォーマットされない", "0.0", "0.0"),
            arrayOf("小数部のみを11個入力できる", "0.00000000000", ".00000000000"),
            arrayOf("小数部のみを12個入力すると末尾が反映されない", "0.00000000000", ".000000000009"),
            arrayOf("整数部のみを12個入力できる", "999,999,999,999", "999999999999"),
            arrayOf("整数部のみを13個入力すると末尾が反映されない", "999,999,999,999", "9999999999990"),
            arrayOf("入力にカンマが付与されていても変換可能", "999,999,999,999", "999,999,999,999"),
            arrayOf("入力のカンマ位置がズレていても変換可能", "999,999,999,999", "99,999,999,9999"),
        )


    // イベントハンドリングのテスト
    // (interface EditTextのMockクラスをここで作成している。割愛)

    /** [Form.EditText.formUpdatedManuallyStream]がトリガーされたときcountDownする */
    private val latch = AtomicReference(CountDownLatch(1))

    private val editText = MockPriceInputEditText().apply {
        formUpdatedManuallyStream.subscribeBy {
            latch.get()?.countDown()
        }.addTo(bag)
    }

    @Test
    @Parameters(method = "provideTestEditTextManualInput")
    fun `testEditTextManualInput_自動入力時はコールバックがコールされる`(expected: String, given: String?) {
        latch.set(CountDownLatch(1))
        given?.let { editText.setText(it) }

        try {
            latch.get()?.await(100, TimeUnit.MILLISECONDS)
            assertEquals(
                expected = expected,
                actual = editText.text,
                message = "「$given のとき $expected になる」"
            )
        } catch (e: InterruptedException) {
            fail("「$given のとき $expected になる」")
        }
    }

    private fun provideTestEditTextManualInput(): Array<Array<out Any?>> =
        arrayOf(
            // expected, given
            arrayOf("1", "1"),
            arrayOf("12", "12"),
            arrayOf("123", "123"),
            arrayOf("1,234", "1234"),
            arrayOf("123,456,789,012", "123456789012"),
            arrayOf("123,456,789,012", "1234567890123"),
            arrayOf("0", ""),
            arrayOf("0.", "."),
            arrayOf("0.0", "0.0"),
            arrayOf("0.00", "0.00"),
            arrayOf("0.00000000000", "0.00000000000"),
            arrayOf("0.00000000000", "0.000000000001"),
        )
}

 CountdownLatch.await()の100msは、もっと短くてよいかもしれません(UnitTestの実行速度は極力高速であるべき)。

スリムになったViewModel

 掲載は割愛しますが、これら以外にも、購入残高を表現するBuyBalanceクラス、購入対象の暗号資産の量、レート、手数料などを表現するBuyTargetクラス(命名がむずかしい!)を作成しました。その結果ViewModelを、コンポーネントの依存関係がストリームで表現されただけの、スリムな実装にすることができました。そしてコメントを付与し、設計や実装の意図をより明瞭に表現します。コメント付与もまた非常に重要なのですが、そちらについては別の機会にお話しできればと思います。

// (BuyViewModelのbind関数より抜粋)

val buyFormRelay = BehaviorRelay.createDefault(BuyForm())

// 入力フォーム(価格、レート単位)の更新
val buyFormStream = Observable.merge(
    // フォーム入力結果をキャッシュする。残高を超えた入力の場合、残高に丸める
    input.formUpdatedManually
        .withLatestFrom(buyFormRelay, balanceStream) { newer, form, balance ->
            form.update(newer)
                .roundIfExceeds(balance)
        },
    // 残高ボタンタップ時、残高を入力フォームに反映する
    input.balanceTopPressed
        .withLatestFrom(buyFormRelay, balanceStream) { _, form, balance ->
            form.update(Form.Auto(balance.of(form.rateUnit)))
        },
    // JPY/BTC建の切り替えボタン押下時、レート単位を入れ替える
    input.changeRateUnitButtonPressed
        .withLatestFrom(buyFormRelay) { _, form ->
            form.toggleRateUnit()
        }
)
    .map { buyFormRelay.accept(it) }

 以上のクラス群をざっと見ただけでも、ロジックをモデル側に寄せることができたと思います(これらのロジックは元々ViewおよびViewModel側に存在しました)。

 BuyViewModelのUnitTestも作成しましたが、こちらも掲載を割愛します。依存関係の正常系シナリオのUnitTestを書いてみたのですが、テストコードがかなり複雑になってしまい、あまりうまくいったとは考えていません(一応、テストコード最上段に「このテストをすべてのViewModelのテストのベストプラクティスとする想定はありません」と注意書きはしました)。

 確認に手間がかかるフローやエッジケースについてはUnitTestを作成した価値がありました。一方、よく使われるフローについては、正常系のシステムテストを実施できればそれで充分だと考えます。UnitTestは単に増やすものではなく、効率よく品質保証を行うための手段のひとつと捉えています。

まとめ

 設計手法そのものよりも、設計手法をどのように噛み砕いて設計・実装したのか、なるべく具体例をご紹介できればと思い、本記事の執筆にいたりました。チームで設計を議論して意思決定するのはそれなりにコストがかかります。自分自身や相手の意見にどこまで客観性があるのか考えたり、そのことをお互いに言語化し納得できるまで意見交換していくのはむずかしく、そもそもの言語化が困難だったり、用語の認識がズレていたり、議論が抽象的になりすぎたりと、いろいろと課題も多いと思います5。一方で「UnitTestを実際に手を動かして実装できるかどうか」であれば、各々の実装経験から感覚的に判断しやすいと思いますし、結果も共有しやすいです。少なくとも筆者の所属するチームのひとまずの共通言語として「UnitTestを書けるかどうか」は機能しやすいように思えました。

 以上です。ここまでお読みいただきまして、ありがとうございました。

 最後にコインチェックではエンジニアを積極募集中です。今話題のWeb3、ブロックチェーンの領域で勝負したいエンジニアの方などご興味ある方はお気軽にお申し込みください!

engineer-recruit.coincheck.com

 またコインチェックに興味がある方、転職を検討している方に向けて社内の情報をゆるく発信していくCoincheck FMもやっておりますので、よろしければ聞いてみていただけると幸いです。

open.spotify.com


  1. 解説のために省略した箇所が多数存在します。実際のプロダクトコードとは異なります。また動作保証も致しかねます。
  2. 複雑なコードの持つ課題は「実装の意図が分かりづらい」「コードが長すぎて読みづらい」「依存関係が整理されていない」「UnitTestが書けない」などさまざまな観点からの表現が可能です。しかしいずれの表現の場合も良し悪しの判断基準が客観的でなければチーム内での議論は進みにくいと考えます。比較的近いキャリアをもつメンバー同士でも、コードの読み方がまったく異なることもあります。「コードの読みやすさ」ひとつとっても(読みやすさの客観的基準も存在はしますがそれでもやはり)人によって考え方・感じ方は異なるでしょう。「複雑な設計・実装でもUnitTestを書くことができるか」は、その判断基準のひとつとしてチーム内での共通認識にしやすいと筆者は考えます。
  3. 「単一責任の原則」を念頭に置いて分類しました。
  4. 気になる場合はformUpdatedManuallyStream.distinctUntilChanged()などを挟む手もあります。もちろん要件にもよりますが、非同期処理のイベントハンドリングにおいて、イベントの発火タイミングや複数のストリーム発火順序を厳密に制御することはかなりむずかしいので、それが多少冗長になったり不確実になっても問題ないようなモデル設計をする方向で全体を構成したほうがよいという考えもあると思います。
  5. たとえばDDD(ドメイン駆動設計)のような抽象的、思想的概念は、人によって理解度や解釈に差が出やすいと思います。筆者にはそれらすべてをうまく説明してチームの共通言語にできる自信はありません。新たな概念の学習や共有そのものは楽しいのですが、その合理性に固執するだけだとチームとしてはあまりよい結果を得られないと考えています。今一緒に仕事をしているメンバーが、どういうふうにコードを読み、仕様を把握し、何をモチベーションとしているのかといったことを意見交換しつつ、共通言語を探したり、ときには自分たちで作ったりしながら、品質向上に励んでいます。