AndroidのE2Eテスト / UIテストでのwaitの仕方ベストプラクティス

# はじめに

E2Eテスト / UIテスト って遅いし安定しないし、モバイルエンジニアなら大抵の人は、好きではない気がしています。クラウド上だと安定しないから実機に接続したりですね。

クラウド上の話は別として、それらのプログラム面での実装のほとんどは、waitの仕方に問題があるんじゃないかなと思ってたりします。
ネットで調べても、安定して比較的早く終わる方法でプログラム実装をしている記事をほぼ見たことが無いので、ここで僕なりのベストプラクティスをまとめておこうと思います。
（あくまで、僕がやってきたベストプラクティスなので、もっといい方法があれば教えて貰えると嬉しいです）

# よくない実装

安定しないから、waitを入れるとは思いますが、こんな実装をしてたら良く無いですよ。という実装です。
検索すると出てくる方法です。

```kotlin
SystemClock.sleep(5_000)
```

Thread.sleep()のように`InterruptedException`が出ないので上記のようにやればいんだよ。的なことを書いてる古い記事はありましたが、直感的に良くないとわかると思います。
ただし、それをいうなら以下も同じです。公式以外の方法では、この実装している人も多い気がしてます。

```kotlin
fun wait(delay: Long = 5_000): ViewAction {
    return object : ViewAction {
        override fun perform(uiController: UiController?, view: View?) {
            uiController?.loopMainThreadForAtLeast(delay)
        }

        override fun getConstraints(): Matcher<View> {
            return isRoot()
        }

        override fun getDescription(): String {
            return "wait for " + delay + "milliseconds"
        }
    }
}
// waitしたいところでこんな感じで使います
// Espresso.onView(ViewMatchers.isRoot()).perform(wait())
```

何が良く無いかというと成功しようが失敗しようが関係なく、条件なく待ち続けることです。
この例の場合は5秒程度ですが、ちりつもですし、安定しないからといって、50秒とか普通に設定してしまいがちです。

大抵は、サーバーを待つためにwaitを入れると思いますが、どれくらい待てばいいのかはわからないので、おそらく短すぎるか長すぎるかのどちらかになっているでしょう。

そもそもEspressoはsleepを避けるために作られているのにsleepと同じことをするのはやめましょう。

# 公式での解決方法

IdlingResourceで実装することを勧めています。
https://developer.android.com/training/testing/espresso/idling-resource?hl=ja

Espressoは自動同期機能があり、画面更新の完了を自動的に待ち合わせてくれるのですが、以下の場合だけ待ってくれます。
* メインスレッドの待ち
* AsyncTaskのバックグラウンド処理
* IdlingResourceの実装

つまり、CoroutinesやRxJava、JavaのExcetutorやThread、Handlerでは待ってくれないようです。（最新の環境で僕が全部試したわけではない）

なので、IdlingResourceで独自に実装をしていくのですが、
IdlingResourceの場合は、本番コードにも記述する必要があります。`@VisibleForTesting` を記述するのですが、本番コードに本質的でないコードを書きたく無いです。

たとえ、本番環境に書かなくて大丈夫だとしても、以下の解決方法でCoroutinesを多用しているプロジェクトで簡単に解決できているので、僕的には以下がベストプラクティスだと思ってます。

# 解決方法

まずは、指定したリソースIDが見つかるまで待つメソッドを用意しときます。

```kotlin
fun <T : ViewInteraction> T.waitShown(@IdRes resId: Int, timeout: Long = 10_000): T {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val uiDevice: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    val bySelector: BySelector = By.res(<パッケージ名>, context.resources.getResourceEntryName(resId))
    val searchCondition: SearchCondition<UiObject2> = Until.findObject(bySelector)
    uiDevice.wait(searchCondition, timeout)
    return this
}
```

使い方はこんな感じです。

```kotlin
object NewsFeedPage : PageObject {
    fun waitUntilFeedShown() = apply {
        Espresso.onView(ViewMatchers.isRoot())
            .waitShown(R.id.news_feed_section_title)) // ここではリスト上に表示されるidを指定
    }
}
```

次に画面遷移やアニメーションが終わるまで待機するメソッドを用意します。これは、WebViewの表示も待ってくれます。


```kotlin
fun <T : PageObject> PageObject.waitUntilPageShown(timeout: Long = 10_00): T {
    val uiDevice: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    uiDevice.waitForWindowUpdate("<パッケージ名>", timeout)
    uiDevice.waitForIdle(timeout)
    return this as T
}
```

僕は、E2Eテストの時は必ずPage Objectパターンを使うので、`PageObject`インターフェイスを作り、そこの拡張メソッドとして用意していますが、特に普通のメソッドとして用意しても構いません。
これを画面遷移の後に必ず書くようにします。

使い方はこんな感じです。

```kotlin
LoginPage
    .typeUserName(id)
    .typePassword(pass)
    .tapLogin()
    .waitUntilPageShown<NewsFeedPage>() // 上のログインが非同期で行われるが、ここで画面遷移が終わるまで待ってくれる
    .waitUntilFeedShown() // ここでリストの中身が表示されるまで待機    
    .tapNews()
```

ちなみに `waitUntilPageShown()`を拡張メソッドとして作ったことで、PageObjectパターンを使っていても、処理を見ればどこの画面の処理をしているのかが分かりやすくなるというメリットもあります。(たとえば、拡張メソッドがないと`tapNews()`がどこのPageの操作メソッドがわからない)

ほとんどの場合はこれだけで解決できるのですが、まだまだです。
スクロールするテストをする時に以下の実装にすると早すぎて、安定せずにエラーになる場合があるのでこれを解決する必要があります。

```kotlin
// RecyclerViewのこれだと早すぎる
Espresso.onView(
    Matchers.allOf(
        ViewMatchers.withId(R.id.news_feed_recycler_view),
        ViewMatchers.isDisplayed()
    )
).perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(position))

// WebViewの場合も早すぎる
Web.onWebView()
    .withElement(
        findElement(Locator.ID, id)
    ).withTimeout(20, TimeUnit.SECONDS).perform(webScrollIntoView())
```


なので、こんなクラスをあらかじめ実装しておきます。

```kotlin
class TranslatedCoordinatesProvider(
    private val coordinatesProvider: CoordinatesProvider,
    private val dx: Float,
    private val dy: Float,
) : CoordinatesProvider {

    override fun calculateCoordinates(view: View): FloatArray {
        val xy = coordinatesProvider.calculateCoordinates(view)
        xy[0] += dx * view.width
        xy[1] += dy * view.height
        return xy
    }
}
```

で、 以下の二つのメソッドを用意しておきます。人間が操作しているように、ゆっくりとスクロールしてくれます。

```kotlin
fun swipeDownSlowly(): ViewAction? {
    return ViewActions.actionWithAssertions(
        GeneralSwipeAction(
            Swipe.SLOW,
            TranslatedCoordinatesProvider(GeneralLocation.TOP_CENTER, 0f, 0.083f),
            GeneralLocation.BOTTOM_CENTER,
            Press.FINGER))
}

fun swipeUpSlowly(): ViewAction? {
    return ViewActions.actionWithAssertions(
        GeneralSwipeAction(
            Swipe.SLOW,
            TranslatedCoordinatesProvider(GeneralLocation.BOTTOM_CENTER, 0f, -0.083f),
            GeneralLocation.TOP_CENTER,
            Press.FINGER))
}

object WebViewPage : PageObject {
    fun swipeUpSlowlyForWebView(@IdRes resId: Int = R.id.web_view) = apply {
        onView(withId(resId)).perform(swipeUpSlowly())
    }

    fun swipeDownSlowlyForWebView(@IdRes resId: Int = R.id.web_view) = apply {
        onView(withId(resId)).perform(swipeDownSlowly())
    }
}
```

使う時は、こんな感じになるだけです。

```kotlin
.goWebViewPage()
.waitUntilPageShown<WebViewPage>()
.swipeUpSlowlyForWebView()
.swipeDownSlowlyForWebView()
.tapBackArrow()
.waitUntilPageShown<NewsFeedPage>()
```

# まとめ

* 画面遷移やアニメーションが終わるまで待ちたい時は、自作の`waitUntilPageShown()`を使う
* 指定したIDが表示されるまで待ちたい場合は、自作の`waitShown()`を使う
* スクロール操作をするときは、自作の`swipeDownSlowly()`などを使う

僕はこの方法でテストをして、プログラムだけの修正で既存の40%のスピードアップと安定化を実現できましたので、お勧めです。

以上です。