Kyash Android で UIテストを導入した時の方針 - Konifar's WIP

Konifar's WIP

親方!空からどらえもんが!

Kyash Android で UIテストを導入した時の方針

先日、KyashAndroidアプリのUIテストをEspressoで書いてCIで回すようにしました。

ユーザー登録、ログイン、カード登録のテストが、毎晩元気に走っています。

f:id:konifar:20180813174047g:plain

きっかけはKPTです。iOSでログイン直後に発生するユニットテストでは気づけない問題が見つかり、 Problem として上がってきました。

テストケースを15項目くらいSpreadSheetにまとめていて大きなリリースの時は手動で確認していたのですが、なぜか「今回はやらないで大丈夫」と判断した時に限ってバグが発生するんですよね。また、手動のテストケースはミスや形骸化しやすいので、 Try としてテストケースの一部を自動化してみることにしたのでした。

Espressoでテストを書く時は、@sumio_tymさんの素晴らしい資料があるので、下記2つを読んでおけば間違いないと思います*1

今回は、導入・メンテナンスのしやすさを考慮して決めたいくつかの方針についてまとめておこうと思います。まだ運用にのせたばかりなので、もしもっといいやり方があれば変えていくので@konifarまで教えてください。

パッケージ・クラス

どこに何を書けばいいか明確でないと新しいテストを書く時に迷ってしまうので、パッケージとクラスの分割指針を次のように決めました。

/src/androidTest
|--AndroidManifest.xml
|--java
|  |--co
|  |  |--kyash
|  |  |  |--AndroidTestApp.kt
|  |  |  |--di
|  |  |  |  |--TestAnalyticsModule.kt
|  |  |  |  |--TestNetModule.kt
|  |  |  |  |--...
|  |  |  |--pageobject
|  |  |  |  |--account
|  |  |  |  |  |--AccountSettingPageObject.kt
|  |  |  |  |  |--...
|  |  |  |  |--card
|  |  |  |  |  |--AboutLinkedCardPageObject.kt
|  |  |  |  |  |--...
|  |  |  |  |  ...
|  |  |  |  ...
|  |  |  |--scenario
|  |  |  |  |--AddLinkedCardTest.kt
|  |  |  |  |--LoginTest.kt
|  |  |  |  |--SignInTest.kt
|  |  |  |  |--...
|  |  |  |--testing
|  |  |  |  |--AndroidTestUtils.kt
|  |  |  |  |--CustomTestRunner.kt
|  |  |  |  |--DummyEventTracker.kt
|  |  |  |  |--MoreViewMatchers.kt
|  |  |  |  |--RecyclerViewUtils.kt
|  |  |  |  |--...

トップレベルのパッケージ構成はproductionコードとは全く別物です。それぞれの命名と役割は次のようになっています。

di

Kyashではdagger2を使っていて、UseCase、Repository、API(Retrofit)といったレイヤーごとのクラスを使用するクラスのコンストラクタでinjectするようにしています。

テスト実行時にGoogleAnalyticsやAPIクライアントの実装を置き替えるために、ダミーのmoduleを di パッケージに入れています。

もともとユニットテストはわりと書いていて、適度にレイヤーを分けてモック・スタブしやすい作りにしていたので楽チンでした。もしDIを使っておらずstaticなクラスやシングルトンが多かったら、いったんラッパーで包んで @VisibleForTesting アノテーションをつけたsetメソッドを作って対応すると思います。

pageobject

PageObjectパターンで各画面で必要な操作や検証をクラスにまとめ、 pageobject パッケージに入れています。

次のログイン画面を例にコードを見てみましょう。

f:id:konifar:20180813111412j:plain

object LoginPageObject {
    ...

    /**
     * メールアドレスの入力
     */
    fun inputEmail(text: String) = apply {
        inputText(R.id.email_edit, text)
    }

    /**
     * パスワードの入力
     */
    fun inputPassword(text: String) = apply {
        inputText(R.id.password_edit, text)
    }

    /**
     * Facebookログインボタンのクリック
     */
    fun clickFacebookLoginButton() = apply {
        onView(withId(R.id.facebook_button)).perform(scrollTo(), click())
    }

    /**
     * Emailログインボタンのクリック
     */
    fun clickEmailLoginButton() = apply {
        onView(withId(R.id.button)).perform(scrollTo(), click())
    }

    private fun inputText(@IdRes id: Int, text: String) {
        onView(withId(id)).perform(scrollTo(), replaceText(text), closeSoftKeyboard())
    }
}

見てわかるとおり、文字の入力やボタンのクリックといった操作をPageObjectにまとめています。

こうしておくことで、シナリオを実行するテストクラスはEspressoに依存せず、PageObjectのメソッドを呼び出すだけになります。ログインボタンのidや文言が変わった時にも PageObject内の一箇所を修正するだけで済みます。慣れてくると画面を見ながらPageObjectをシュッと書けるようになるので、Espresso Test Recorderは使っていません。

Kotlinの場合、各メソッドに = apply { をつけておくと、呼び出す側はこんな感じでメソッドチェーンのように書けるのでとてもよいですね。

LoginPageObject
    .inputEmail("taro@kyash.co")
    .inputPassword("kyash123")
    .clickEmailLoginButton()

後述しますが、Kyashではテストサーバーやモックサーバーを介さずAPIクライアントをモックするようにしていて、PageObject内でモック用のメソッドを用意しています。

ここはどうしようか少し迷ったんですが、画面ごとに必要な前処理なのでPageObjectにまとめてシナリオテストの中で都度呼び出すようにしました。

scenario

実際にJUnitテストを実行するクラスを scenario パッケージに入れています。

例えば LoginTest.kt では、こんな感じでEmailのログインとFacebookのログインの2つのテストを作っています。

@LargeTest
@RunWith(AndroidJUnit4::class)
class LoginTest {

    @get:Rule
    var activityTestRule = ActivityTestRule(SplashActivity::class.java, true, false)

    @Before
    fun setUp() {
        val facebookSdkUtil = WelcomePageObject.mockFacebookSdk()

        val kyashApi = mock<KyashApi>().apply {
            SplashPageObject.mockApi(this)
            SignInPageObject.mockApi(this)
            MainPageObject.mockApiForEmptyData(this)
        }

        val app = AndroidTestUtils.getApp()
        app.reloadDagger(kyashApi, facebookSdkUtil)
        app.logout()
    }

    /**
     * Emailでログインしてウォレット画面を開くまで
     */
    @Test
    fun emailLogin() {
        SplashPageObject.launch(activityTestRule)

        WelcomePageObject
                .waitUntilShown()
                .clickLoginButton()

        LoginPageObject
                .inputEmail("taro@kyash.co")
                .inputPassword("kyash123")
                .clickEmailLoginButton()

        WalletPageObject.assertKyashCardInActiveExists()
    }

    /**
     * FacebookでログインしてKyashカードを発行せずにウォレット画面を開くまで
     */
    @Test
    fun facebookLogin() {
        SplashPageObject.launch(activityTestRule)

        WelcomePageObject
                .waitUntilShown()
                .clickLoginButton()

        LoginPageObject.clickFacebookLoginButton()

        WalletPageObject.assertKyashCardInActiveExists()
    }

前処理では、APIFacebook SDKをモックしてDIでinjectしたり、logout状態にしたりしています。このやり方はあまりスマートではないので、もっといいやり方があれば知りたいところです。

テストケースの中ではPageObjectのメソッドを呼び出してシナリオを作っています。

ログインやカード発行など、共通の条件がいくつか出てきたら、いくつかの PageObject の呼び出しをまとめた LoginTask のようなクラスを作って task パッケージの中にいれておいてもいいかもしれません。

testing

EspressoでUIのテストを書く上で必要なUtilityやMatcherを testing にまとめています。もしかしたら今後ここに色んなクラスができてくるかもしれませんが、そうなったらその時に分割方針を考える予定です。

外部との通信

UIのテストを実行する上で、APIとのやりとりをどうするかは悩ましいところです。

大きく次の3つの選択肢があります。

1. テスト用のサーバーを用意してつなぐ

サーバーの振る舞いも含めて結合テストしたい場合は、このやり方がよいでしょう。

APIの仕様が予期せず変更された場合や、エラーが置きている場合も検出できます。

実際に操作するのとほぼ同じ条件でテストできるというメリットがある一方で、実行時に常に同じ状態にしたり同時に実行されたりした場合を考慮すると、テスト実行ごとにdockerを立ち上げる等の工夫が必要になります。

2. モックサーバーを使う

サーバーは期待通りの振る舞いをする前提で、APIクライアントからView層までをテストしたい場合は、このやり方がよいでしょう。

okhttpのmockwebserverを使えば、簡単に導入できますし、プロジェクト内のコードで完結する分、テストサーバーを実際に用意するのに比べて運用しやすいです。

一方で、モック用のjsonを用意しておかなければならない分、手間がかかるしAPI側の変更や追加に追従できなくなる可能性もあります。

3. APIクライアントをモックに置き換える

サーバーやAPIクライアントは期待通りの振る舞いをする前提で、それより上のレイヤー(Kyashの場合はRepository〜View)をテストしたい場合は、このやり方がよいでしょう。

ユニットテストと同じようにAPIクライアントをモックすればよいため他の2つと比べてかなりメンテナンスしやすい一方で、APIjsonとパースでバグがあった場合には気づけないという問題もあります。


これらをkibelaに書いてチームメンバーと議論し、最初の導入やメンテナンスのコストを考慮してKyashとしては 3. APIクライアントをモックに置き換える でやってみることにしました。

今までバグが起きていた部分は3のやり方でも拾えそうだったということと、とりあえずまずは導入して運用にのせるところまでをやりたかったというのが大きな理由です。

いきなり完璧にやろうとして途中でしんどくなってしまったら嫌ですし、どちらにしろ状況に応じてモックする方式と共存していくことになるだろうなとも思ったんですよね。

ちなみに、androidTestでmockitoを使う時はorg.mockito:mockito-androidをdependenciesに追加すれば使えます。

Facebookログインについては、SDKのラッパーを作っていたのでそれをモックして対応しました。callbackクラスをパラメータで渡す作りでテストを書きにくかったので、先に rx.Singlerx.Completeを返すようにリファクタしたりもしました。

非同期処理

基本的に sleep() は使わない方針です。

Espressoテストコードの同期処理を究めるに書かれているIdlingResourceを使ったり、RxIdlerを入れたりして実装したかったのですが、導入してみたところうまくいかずハマってしまったのでいったん諦めました。

APIクライアントをモックしているので、ユニットテストと同じように RxJavaPluginsRxAndroidPlugins を使って結果がすぐに返るようにすることで対応しています。

どうしても待ち合わせが必要な時は、UI Automatorを使っています。これもPageObjectに次のようなメソッドを作っています。

fun waitUntilShown() = apply {
    val result = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
            .wait(Until.hasObject(By.text(getString(R.string.welcome_email_sign_up))), 5000)
    assertTrue(result)
}

CIと実行タイミング

KyashのAndroidプロジェクトのCIはCircleCIを使っていましたが、次の理由でUIテストにはBitriseを使うことにしました。

  1. CircleCIのキューに時間のかかるタスクを積みたくない
  2. iOSはBitriseを使っていて課金もしている
  3. (CircleCIと比べて)定期実行の設定をGUIで簡単にできる
  4. Virtual Device Testing for AndroidでFirebase Test Labとの連携もGUIで簡単にできる

4つめに関しては、設定項目をいくつか入力するだけで動くしテスト中のスクリーンのコンソール上で動画で確認できてよかったです。

blog.bitrise.io

Bitriseのプランによると実行時間が40分を超えるとタイムアウトしてしまうようですが、そうなった時はそもそもテストをどうにかした方がいいので今はあまり気にしていません。

PushやPull Requestのたびに実行すると時間がかかってしまうので、毎晩0時と release ブランチにマージされた時に実行するようにしました。


まとめ

こんな感じの方針を決めながら、だいたい一週間くらいで導入できました。 実際にテストを書く中で一個バグも見つけられたのでよかったです。

PageObjectに画面ごとの操作や検証をまとめることで、それなりにメンテナンスしやすいシナリオテストを書けるように整備できたと思います。

UIテストを書く前は「ちゃんとやろうとすると導入めんどくさいなぁ」と思っていたのですが、APIクライアントをモックしたりBitriseを利用したりとまずは運用で回すことを第一に考えたのがよかったのかもしれません。

KyashではViewModelのユニットテストを書くことで画面遷移も含めてほとんどのロジックを検証できる作りにはなっているのですが、必要に応じてUIのテストも書いていこうと思います。

*1:外山さんには、オフィスで直接色々とアドバイスもいただきとても感謝しています。