Takuji->find;

株式会社はてなでアプリケーションエンジニアやってます、技術的な記事を書いているつもり

Androidアプリの非同期なテストを書く時の悩みをEspresso Idling Resourceで解決する #関モバ

こんにちは、最近TDDに回帰している id:takuji31 です。

最近 Espresso Idling Resource を使い始めたので紹介します。

なお、この記事の一部は本日開催の関西モバイルアプリ研究会#24で発表された内容です。

UIテストを書く時の悩み

  • 何かが切り替わる時にUIのアニメーションが発生して、その後表示が切り替わるようなものをテストしたい
  • APIからデーターを非同期に取得して、画面に表示される値が正しいかテストしたい

↑のようなテストをする時、 “テストが可能になるまで待つ” という動作を実装するのは結構面倒です。

雑な解決方法

面倒なのでよくやるのは Thread.sleep(1000) みたいな感じで十分な時間待つことですね。

ですがこの対処方法には「待ったところで実は終わってないかもしれない = テストがたまにコケる」 という問題があります。

ちょっと賢い方法

われわれはかしこいので、RxJavaなんかを使って完了を待つObservableを作ったり、完了を待ち受けるListenerを作ったりするかもしれません。

これは確実に完了まで待つことができますが

  • 複数のコンポーネントの完了を全て待つ時の実装が複雑になりがち
  • そもそもテストのためにそんなものを手で時間をかけて実装するのは本質からそれているのではないか

といった問題があります。

Espresso Idling Resource

↑のような問題を解決するのが Espresso Idling Resource です。

これはEspressoのプラグインのようなもので、アイドリングの仕組みを提供してくれます。

このライブラリーで用意されている IdlingRegistry に自前の IdlingResource を登録すれば、そのリソースがアイドリング状態になるまで、テストが先に進むのを止めてくれます。

使い方

セットアップ

build.gradleに設定

compile 'com.android.support.test.espresso:espresso-idling-resource:3.0.0'

IdlingResource

まずは IdlingResource を作ります。

fun getName(): String

このIdlingResourceの名前を返します。この名前はロギングや二重登録のチェックに使われるようです。

fun isIdleNow(): Boolean

このリソースがアイドル状態かどうかを返します。

fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?)

アイドル状態になった時に実行するCallbackを登録します。

callbackはnullableのようです。

実装

ここではめちゃくちゃ簡単にしたいので、propertyにBooleanの値をセットするだけのものにします。

class SimpleIdlingResource(private val name: String) : IdlingResource {

    private var callback: IdlingResource.ResourceCallback? = null
    var isIdle: Boolean = false
        set(value) {
            field = value
            if (value) {
                callback?.run { onTransitionToIdle() }
            }
        }

    override fun getName(): String = this.name

    override fun isIdleNow(): Boolean = isIdle

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
        this.callback = callback
    }
}

使う

アプリ側

アプリ側で SimpleIdlingResource を使うコードを書きましょう

class ViewModel {

    var artists: List<Artist> = emptyList()

    var loadingDisposable: Disposable? = null

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    val loadingIdlingResource : SimpleIdlingResource = SimpleIdlingResource("IdlingResourceViewModelLoading")

    fun onCreate() {
        // fake API request
        fakeReload()
    }

    private fun fakeReload() {
        loadingDisposable = Single
                .timer(3, TimeUnit.SECONDS)
                .doOnSubscribe { loadingIdlingResource.isIdle = false }
                .doFinally { loadingIdlingResource.isIdle = true }
                .subscribe { _, _ -> artists = Artist.list }
    }

    fun onDestroy() {
        loadingDisposable?.run {
            dispose()
            loadingDisposable = null
        }
    }
}

APIを叩いてデーターを取得したような感じで3秒待ってリストを更新する処理を書きました。

処理の開始と終了時に loadingIdlingResource.isIdle の値を更新しています。

テストコード

テストでActivityを起動する前に IdlingRegistryIdlingResource を登録します。

@Before なメソッドでやってやるのがよいでしょう。

class IdlingResourceActivityTest {
    @JvmField
    @Rule
    val rule : IntentsTestRule<IdlingResourceActivity> = IntentsTestRule(IdlingResourceActivity::class.java, true, true)

    var initializeIdlingResource : IdlingResource? = null

    @Before
    fun setUp() {
        initializeIdlingResource = rule.activity.viewModel.loadingIdlingResource
        IdlingRegistry.getInstance().register(initializeIdlingResource)
    }
    // ...
}

テスト後には IdlingRegistry から IdlingResource を削除してやります。

@After なメソッドでやってやるのがよいでしょう。

class IdlingResourceActivityTest {
    // ...
    @After
    fun tearDown() {
        initializeIdlingResource?.run {
            IdlingRegistry.getInstance().unregister(this)
            initializeIdlingResource = null
        }
    }
}

テストは普通にEspressoで書きます。

class IdlingResourceActivityTest {
    @Test
    fun testReload() {

        // remove 3 items
        repeat(3) {
            openActionBarOverflowOrOptionsMenu(rule.activity)
            onView(withText("Remove first")).perform(click())
        }

        onView(withId(R.id.swipeRefreshLayout)).perform(swipeDown())

        onView(withId(R.id.recyclerView)).check { view, _ ->
            val recyclerView = view as RecyclerView
            assertEquals(recyclerView.childCount, Artist.list.size)
            assertEquals(recyclerView.adapter.itemCount, Artist.list.size)

        }
    }
}

用意されているMatcherやViewActionを使わない場合は Espresso.onIdle() を呼ぶとアイドル状態になるまで同期的に待ってくれます。

デフォルトで用意されているIdlingResource

CountingIdlingResource

セマフォっぽいやつ、 increment()/decrement() を呼んでカウントが増減し、0ならアイドルになる、1以上ならアイドルじゃないとなるようです。

CountingIdlingResource | Android Developers

IdlingScheduledThreadPoolExecutor / IdlingThreadPoolExecutor

ThreadPoolExecutor、IdlingScheduledThreadPoolExecutorは遅延実行できる。

IdlingScheduledThreadPoolExecutor | Android Developers

UriIdlingResource

UriをキーにできるIdlingResource、ドキュメントを見た感じWebViewと使うことを想定してるっぽい?

UriIdlingResource | Android Developers

ハマりどころ

Proguardを有効にした状態でテストを実行すると、IdlingRegistryがなくてコケる。

Proguardの設定を書きましょう。

-keep class android.support.test.espresso.** {
    public *;
}

最後に

割と簡単に実装できるので、Thread.sleepやめてIdlingResource使いましょう。