このCodelabでは、Espressoを使ったAndroidのUIテストの書き方を学びます。

学べること

事前知識として必要なもの

推奨環境

Android端末の設定

題材アプリ

GoogleがAndroid Jetpackのサンプルアプリとして公開しているAndroid Sunflowerの2019年5月18日時点のソースコードをベースに、少し改修を加えたものを題材として使用します。改修内容としては、JavaからKotlinへの書き換えなどを行いました。

主要な機能は以下のとおりです。ビルドをして確認してみてください。

sunflower screenshotssunflower screenshotssunflower screenshots

EspessoでUIテストを書くために、まずテスト対象の画面を起動する必要があります。
ActivityとFragment、それぞれ起動するためのAPIを紹介します。

Activityの起動

AncroidX Testには、Activityの起動やステートの変更を行えるActivityScenarioというクラスが含まれています。

ActivityScenarioは、local testsからでもinstrumented testsからでも使えるAPIです。

ActivityScenarioを利用するためには、app/build.gradleに次の設定が必要です。

dependencies {

    // local testで使いたい場合
    testImplementation 'androidx.test:core-ktx:X.X.X'

    // instrument testで使いたい場合
    androidTestImplementation 'androidx.test:core-ktx:X.X.X'
}

ActivityScenarioRuleをRuleに設定すると、各テストの開始時にActivityScenario.launchを、テスト終了時にActivityScenario.closeを自動的に呼びます。

@RunWith(AndroidJUnit4::class)
class MyTestSuite {

    @get:Rule val activityScenarioRule = activityScenarioRule<MyActivity>()

    @Test fun test() {
        //テストコード
        onView(withId(R.id.text)).check(matches(withText("Hello World!")))
    }
}

この時、作られたActivityはRESUMEDステートになります。
RESUMEDステート中のActivityは、ユーザーに対してvisible状態であり、ユーザーはActivityのView要素を操作することができます。

Fragmentの起動

FragmentもActivityと同様に、テストにおいて、Fragmentの生成から状態変更までを操作するためのFragmentScenarioというAPIが用意されています。

こちらも、local testsからでもinstrumented testsからでも使えるAPIです。

FragmentScenarioを利用するためには、app/build.gradleに次の設定が必要です。

dependencies {
    debugImplementation 'androidx.fragment:fragment-testing:X.X.X'
}

FragmentScenarioには、次のタイプのフラグメントを起動するメソッドが組み込まれています。

グラフィカルフラグメントの起動コード

@RunWith(AndroidJUnit4::class)
class MyTestSuite {

    @Test fun testEventFragment() {

        val scenario = launchFragmentInContainer<MyFragment>()

        // テストコード
        onView(withId(R.id.text)).check(matches(withText("Hello World!")))
    }
}

非グラフィカルフラグメントの起動コード

@RunWith(AndroidJUnit4::class)
class MyTestSuite {

    @Test fun testEventFragment() {

        val scenario = launchFragment<MyFragment>()

        // テストコード
        // Espressoのテストを実装するとExceptionになる
    }
}

どちらの起動方法でも、FragmentScenarioはテスト中のフラグメントの状態をRESUMEDに変更します。
また、プロダクトコードでどのActivityにアタッチされているかに関係なく、テスト用の空のActivityにアタッチされて起動します。

FragmentScenarioの引数

FragmentScenaioは次の引数をとります。

引数名

引数型

概要

fragmentArgs

Bundle

Fragment起動時のargument

themeResId

int

FragmentのUIテーマ
Material Designのコンポーネントなど、テーマの指定が必要なUIコンポーネントがレイアウトにあるときは指定をしないとエラーになる

factory

FragmentFractory

Fragmentの生成Factory

参考ドキュメント

ActivityとFragmentのテストについて、さらに詳しい情報は公式ドキュメントを御覧ください。

Espressoを使ってテストコードを書くときの基本構造について学びます。
すでに精通している場合は、次ページへと進んでください。

基本構造

Espressoを使ったテストコードは、原則として下に示した基本構造を繰り返して書くことになります。

onView(ViewMatcher).perform(ViewAction).check(ViewAssertion)

この基本構造の処理内容は下のようになります。

  1. ViewMatcherで指定された条件を満たすViewを探す(onView)
  2. 条件に合致したViewに対して、クリックやスクロールといったViewActionを実行する(perform)
  3. ViewAssertionで指定したアサーションを満たしているか検証する(check)

条件に合致するViewが複数存在する場合や、合致するViewが見つからなかった場合は例外が発生します。

ViewMatcherの書き方

onViewの引数、ViewMatcherはどのように書けばよいのかを説明します。
ViewMatcherには、Matcher<View>型を返すメソッドを指定します。
そのメソッドには色々な種類があるので、よく使うものを下の表にまとめました。

メソッドはandroidx.test.espresso.matcher.ViewMatchersに定義されており、返り値の型はいずれもMatcher<View>です。

メソッド名

引数の型

メソッドの概要

withId

Int

引数とリソースIDが一致するViewとマッチ

withText

String etc.

引数と表示テキストが一致するViewとマッチ

isDisplayed

引数なし

画面に一部分でも表示されているViewとマッチ

isDescendantOfA

Matcher<View>

引数にマッチしたViewの子孫となるViewとマッチ

hasDescendant

Matcher<View>

引数にマッチしたViewを子孫にもつViewとマッチ

withIdを使った例は次のようになります。これは、idがmessageになっているViewとマッチすることを意味します。

onView(withId(R.id.message))

また、isDescendantOfAやhasDescendantは、Viewの子孫を条件に設定する場合に使用できます。
isDescendantOfAやhasDescendantは名前がややこしいですが、下記の図のような関係になっています。

descendant

子孫を条件として使う例として、同じカスタムViewを複数持つ画面があったとします。

descendant_example

このような画面で、R.id.error_messageを検証したいとき、IDのみの指定では対象のViewを絞ることができません。
そこで、isDescendantOfAの登場です。

次のように書くことで、R.id.name_text_inputの子孫のR.id.error_messageを絞り込むことができます。

// 2つのViewMatcherを**allOf**で囲むことで、アンド条件で検索をする
onView(allOf( 
            withId(R.id.error_message),
            isDescendantOfA(withId(R.id.name_text_input))))

ViewActionの書き方

performの引数、ViewActionとしてよく使うメソッドを下にまとめました。
メソッドはandroidx.test.espresso.action.ViewActionsに定義されており、返り値の型はいずれもViewActionです。

メソッド名

引数の型

メソッドの概要

click

引数なし

目的のViewをクリックする

replaceText

String

目的のEditTextに、引数に指定されたテキストをセットする。もともと設定されていたテキストはクリアする

scrollTo

引数なし

目的のViewが画面内に現れるまでスクロールする

Actionは複数指定することができ、第一引数から順に実行されます。
以下のようにperformを実装した場合、目的のViewまでスクロールした後にクリックをします。

perform(scrollTo(), click())

ViewAssertionの書き方

checkの引数、ViewAssertionとしてよく使うメソッドを下にまとめました。
メソッドはandroidx.test.espresso.assertion.ViewAssertionsに定義されており、返り値の型はいずれもViewAssertionです。

メソッド名

引数型

メソッドの概要

doesNotExist

引数なし

指定された検索条件にマッチするViewが存在しないこと(GONEの状態ではなく、View Treeに存在しないこと)を検証する)

matches

Matcher<? super View>

指定された検索条件にマッチするViewが、matches()の引数に指定された条件を満たすことを検証する

ここまでの内容を踏まえて、例えば、「R.id.buttonのボタンをクリックし、ボタンに表示されるテキストが「pushed」になることを確認する」場合は次のようになります。

onView(withId(R.id.button)).perform(click()).check(matches(withText("pushed")))

checkperformは省略可能です。例えば、「R.id.buttonのボタンを押したら、R.id.textのViewにOKと表示されることを確認する」を検証したい場合は次のように書きます。

onView(withId(R.id.button)).perform(click())
onView(withId(R.id.text)).check(matches(withText("OK")))

最後に、allOf()を使ったViewMatcherに複数の条件を指定する例です。
「リソースIDがR.id.messageで、かつ、表示されているViewを見つけ、そのテキストが「Hello!」であることを確認する」場合は次のようになります。

onView(allOf(withId(R.id.message), isDisplayed())).check(matches(withText("Hello!")));

まとめ

今までご紹介したEspresso APIの関係を図にまとめると次のようになります。
適宜ご参照ください。

Espresso API

また、Espressoの公式サイトでも開発に便利なチートシートを公開しています。
こちらもあわせてご覧ください。

操作対象のViewをみつけるためにViewMatcherで検索条件を指定することを学びました。
この検索条件はたったひとつのViewにマッチするように絞り込む必要があります。
そのために、Viewの階層構造や目的のViewがもつ属性(リソースIDなど)を知る必要があります。
そこで活躍するのがレイアウトインスペクタです。

代表的なレイアウトインスペクタとして次の3つがあります。

今回は、Android Studio付属のLayout Inspectorとuiautomatorviewerの紹介をします。

Android Studio付属のLayout Inspector

Android Studio付属のLayout Inspectorは原則としてデバッグ可能なアプリの画面しか調査できません。
そのかわり、非常に多くのView属性を調査できます。特に画面上に表示されていないViewについて、本当に存在しないのか、visibility属性がgoneの不可視のViewが存在していのか区別できるのはこのツールだけです。

使い方

  1. エミュレータで調べたい画面を開く
  2. 「Tools」->「Layout Inspector」をクリック
  3. 「Choose Process」というモーダルダイアログが開くので、1で開いているエミュレータのプロセスを選択する

上の手順を踏むと、下のような表示がされます。
真ん中にエミュレータで開いている画面が表示されますので、そこから調べたい要素をクリックしてください。
そうすると、左のView Treeにはクリックした要素が階層構造のどこにあるのかが示され、右のProperties Tableには要素の属性の一覧が示されます。

Layout Inspectorの画面

例えば、リソースIDが知りたければ、Properties Tableの「properties」->「mID」を見ると良いでしょう。

uiautomatorviewer

uiautomatorviewerも任意のアプリの画面を調査できます。テストを書くという目的であればこちらのツールでも十分な場合が多いです。
ただ、表示されていないViewは取得できないなど、取得できる情報には限りがあるので、注意が必要です。

使い方

  1. エミュレータで調べたい画面を開く
  2. $ANDROID_SDK_ROOT(or $ANDROID_HOME)/tools/bin/uiautomatorviewer & で起動する
  3. uiautomatorviewer上のDevice Screenshotボタンを押す

上の手順を踏むと、下のような表示がされます。

uiautomatorviewerの画面

左側のペインで調べたい要素をクリックすると、右側のペインに要素の情報が表示されます。

演習対象の画面

Navigation Drawerから「練習用」を選択したときの画面が今回の演習でのテスト対象画面です。

演習用画面

演習

sharedTest配下のcom.google.samples.apps.sunflower.PracticeFragmentTestを開いてください。テストコード内にTODOと書かれている部分があるので、そこを埋めていってください。
Fragmentの起動までは実装されているので、その後のEspressoのコードを実装してください。

実行

この演習で作成したテストは、Local Testとしても、Instrumentation Testとしても動作します。

Run > Edit Configurationsで、それぞれの実行コンフィギュレーションを作成して実行してみてください。

Instrumented Testとしての実行

Edit Configurationsダイアログは次のようになります。

Instrument testのconfiguration

Local Testとしての実行

Edit Configurationsダイアログは次のようになります。

Instrument testのconfiguration

解答例

sharedTest配下のcom.google.samples.apps.sunflower.example.ExamplePracticeFragmentTestに解答例をコミットしているので、適宜ご参照ください。
その他ScenarioAPIの使い方の紹介も含まれているので、興味があるかたはどうぞ参考にしてください。

RecyclerViewはAndroidアプリでリスト画面を実装する際に使用されるUIコンポーネントです。
このセクションでは、EspressoでRecyclerViewをテストする方法を学んでいきます。

RecyclerViewを操作する

RecyclerViewActionsクラスを使うと、RecyclerViewが保持しているアイテムビュー(
RecyclerView.ViewHolder.itemView
)を操作することができます。

RecyclerViewActionsクラスが提供しているメソッドを下にまとめました。

操作対象の指定方法

(操作)スクロールのみ

(操作)スクロール + アクション

ポジション指定

scrollToPosition(int)

actionOnItemAtPosition(int, Action)

アイテムビュー指定

scrollTo(Matcher)

actionOnItem(Matcher, Action)

ViewHolder指定

scrollToHolder(Matcher)

actionOnHolderItem(Matcher, Action)

以下は、ポジション指定を使用した例です。

R.id.recyclerViewが保持しているポジション3のアイテムビューまでスクロールします。
onViewにはRecyclerViewにマッチするViewMatcherを指定します。

onView(withId(R.id.recyclerView))
        .perform(RecyclerViewActions.scrollToPosition(3))

スクロールした後にそのポジションのアイテムビューに対してViewActionを行いたい場合、actionOnItemAtPositionを使用できます。
actionOnItemAtPositionの第2引数にViewActionを設定すると、スクロール後にアクションを実行してくれます。

ポジション3のアイテムビューまでスクロールした後、そのアイテムビューをクリックする例です。

onView(withId(R.id.recyclerView))
        .perform(RecyclerViewActions.actionOnItemAtPosition(3, click()))

RecyclerViewActionsのメソッドは、アイテムビューに対するアクションしか実行できません
そのため、少し複雑なRecyclerViewを操作したいときはViewActionを自作しなければなりません。

たとえば、次のような、各アイテムビューの子ビューにチェックボックスを持つRecyclerViewを考えてみましょう。

アイテムビュー

このチェックボックスをクリックするには、アイテムビューそのものではなく、その子ビューであるチェックボックスをクリックしなければなりません。

以降ではViewActionを自作することで、チェックボックスクリックを実現してみます。

ViewActionを自作してチェックボックスをクリックしてみる

まず最初に、実装すべきViewActionのインターフェイス定義を見てみましょう。

public interface ViewAction {
  /**
   * アクションを起こしたいViewが満たすべきマッチャーを返します。
   */
  public Matcher<View> getConstraints();

  /**
   * (割愛)
   */
  public String getDescription();

  /**
   * 引数に指定されたviewに対してアクションします。
   */
  public void perform(UiController uiController, View view);
}

この3つのメソッドのうち重要なのはperform()の実装です。perform()では、引数として与えられたviewに対するアクションを実行するように実装します。

たとえば、(チェックボックスではなく)アイテムビューをクリックする次のコードでは、click()perform()にはアイテムビューが渡されていることになります。

// ポジション3のアイテムビューに対するクリック
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.
                 actionOnItemAtPosition(3, click())) 

次に、アイテムビューの子孫で、かつ指定されたリソースIDを持つViewをクリックするclickDescendantViewWithId(@IdRes id: Int)の実装を考えてみましょう。次のように使うことを想定しています。

// ポジション3のアイテムビューの子孫である、IDがcheckBoxのViewをクリック
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.
                 actionOnItemAtPosition(3, clickDescendantViewWithId(R.id.checkBox)))

click()clickDescendantViewWithId(R.id.checkBox))に変わっている点だけが、アイテムビューをクリックするコードと違います。

このコードでもclickDescendantViewWithId()perform()に渡されるViewはアイテムビューですから、
次のような方針でperform()を実装すれば子孫のViewをクリックできそうです。

その実装を書き下すと、次のようになります。

override fun perform(uiController: UiController, view: View) {
    //ViewActions#click()の内部実装を引用
    val action = GeneralClickAction(Tap.SINGLE,
            GeneralLocation.VISIBLE_CENTER,
            Press.FINGER,
            InputDevice.SOURCE_UNKNOWN,
            MotionEvent.BUTTON_PRIMARY)

    // viewの実体はアイテムビュー
    // アイテムビューに対してfindViewByIdで子孫のViewを検索        
    val target = view.findViewById<View>(id)

    // 子孫のViewにたいして、clickを実行する
    action.perform(uiController, target)
}

残りの2つのメソッドも実装した完全版のclickDescendantViewWithId(@IdRes id: Int)は次の通りです。

fun clickDescendantViewWithId(@IdRes id: Int): ViewAction {

    return object : ViewAction {

        // アクションを実行するViewをフィルターする
        override fun getConstraints(): Matcher<View> {
            // 指定したIDを子孫に持つViewに対して有効
            // 今回の場合は、R.id.checkBoxを子孫に持つView = アイテムビューに対して有効
            return hasDescendant(withId(id))
        }

        override fun getDescription(): String {
            return String.format(
                    "performing Click Action with id matching: %d", id)
        }

        // どのようなアクションを実行するかを記述する
        override fun perform(uiController: UiController, view: View) {

            //ViewActions#click()の内部実装を引用
            val action = GeneralClickAction(Tap.SINGLE,
                    GeneralLocation.VISIBLE_CENTER,
                    Press.FINGER,
                    InputDevice.SOURCE_UNKNOWN,
                    MotionEvent.BUTTON_PRIMARY)

            // viewの実体はアイテムビュー
            // アイテムビューに対してfindViewByIdで子孫のViewを検索        
            val target = view.findViewById<View>(id)

            // 子孫のViewにたいして、clickを実行する
            action.perform(uiController, target)
        }
    }
}

RecyclerViewActionsが提供するAPIにはマッチャーは含まれていません。
そのため、特定のアイテムビューや、その子孫にマッチするようなViewMatcherが必要な場合は自作しなければなりません。

再び、各アイテムビューの子ビューにチェックボックスを持つRecyclerViewを見てみます。

アイテムビュー

この図では、特に2番目のアイテムビューに着目し、そのツリー構造も併記しました。

もし「n個目のアイテムビュー」にマッチするViewMatcherを作ることができれば、「n番目のアイテムビューのチェックボックス」は、次のような条件(AND条件)で一意にマッチさせることができそうです。

以降では「position番目のアイテムビュー」にマッチするViewMatcherを返すメソッド
withItemViewAtPosition(recyclerView: Matcher<View>, position: Int): Matcher<View>
を自作してみましょう。

このメソッドを使えば、「n番目のアイテムビューのチェックボックス」にマッチするViewMatcherは次のように構築できます。

allOf(
    withId(R.id.checkBox),
    isDescendantOfA(withItemViewAtPosition(withId(R.id.recyclerView), n)))

「n番目のアイテムビュー」にマッチするViewMatcherを自作してみる

前述のwithItemViewAtPosition(recyclerView: Matcher<View>, position: Int)メソッドは、次の条件を全て満たすアイテムビューにマッチするViewMatcherを返します。

カスタムViewMatcherを実装するには、抽象クラス
TypeSafeMatcher<View>
を継承・実装するのが簡単です。

TypeSafeMatcherを継承したObjectから、実装しなければならないメソッドを抜粋しました。

object : TypeSafeMatcher<View>() {

   /**
    * 引数に渡された`view`にマッチしたときは`true`を返す
    * マッチしなければ`false`を返す
    */
    override fun matchesSafely(view: View): Boolean  {
    }

    override fun describeTo(description: Description) {
    }
}

特に重要なのがmatchesSafely(view: View)メソッドです。

このメソッドを、以下の仕様を満たすように実装します。

Espressoは、画面上のViewひとつひとつについて、それを引数にこのメソッドを呼び出していきます(Visitorパターン)。そしてtrueを返したViewを「マッチしたView」とみなすのです。

今回はアイテムビューとマッチさせたいのですから、引数viewに前述の2つの条件を満たすアイテムビューが渡されたときだけtrueを返すように実装します。

改めて2つの条件を確認してみましょう。

それを踏まえてwithItemViewAtPosition(recyclerView: Matcher<View>, position: Int): Matcher<View>の実装を見てみましょう。

fun withItemViewAtPosition(recyclerView: Matcher<View>, position: Int): Matcher<View> {
    return object : TypeSafeMatcher<View>() {

        // 操作の対象にしたいViewがマッチするような条件をこのメソッドに記述する
        // 戻り値がtrueのときにマッチしたとみなされる
        override fun matchesSafely(view: View): Boolean {

            // matchesSafleyに渡されてきた引数 view (アイテムビュー)が
            // 以下の条件を満たすかどうかチェックする
            // 
            // 条件①: 
            //     そのアイテムビューがrecyclerViewにマッチするRecyclerViewに所属していること
            //
            // アイテムビューの親は必ずRecyclerViewのはず
            val parent = view.parent
            if (parent !is RecyclerView || !recyclerView.matches(parent)) {
                // RecyclerView以外のときは早期リターンをする
                return false
            }

            // 次に以下の条件を満たすかどうかチェックする
            // 条件②: そのアイテムビューの位置番号がposition番目であること

            // 親RecyclerViewからposition番目のViewHolderを取得する
            val viewHolder = parent.findViewHolderForAdapterPosition(position)

            // そのViewHolderのアイテムビューと、matchesSafleyに渡されてきた引数 viewが
            // 一致していることをチェックする
            return viewHolder != null && viewHolder.itemView == view
        }

        override fun describeTo(description: Description) {
            description.appendText("with error: ")
            recyclerView.describeTo(description)
        }
    }
}

演習用画面

Navigation Drawerから「Plant list」を選択したときの画面が今回の演習でのテスト対象画面です。

RecyclerView演習用画面

演習

androidTest配下のcom.google.samples.apps.sunflower.PlantListRecyclerViewTestを開いてください。テストコード内にTODOと書かれている部分があるので、そこを埋めていってください。

解答例

androidTest配下のcom.google.samples.apps.sunflower.example.ExamplePlantListRecyclerViewTestに解答例をコミットしているので、適宜ご参照ください。

UIテストで直面しがちな問題のひとつに画面更新の完了を待たずに検証を行ってしまうというものがあります。
例えば、つぎのようなテストケースを考えてみます。

  1. ユーザーIDとパスワードを入力してログインボタンを押す
  2. 3秒間スリープし、ログインが完了するのを待つ
  3. ログイン完了後、画面上に表示される「ログイン成功」のメッセージが表示されていることを確認する

手順2でログイン完了するまでの時間は一定ではなく、1秒で終わることもあれば、10秒かかることもあります。
10秒かかるとき、テストは失敗します。1秒で終わるとき、テストは失敗しませんが、2秒余分にテスト実行時間が長くなります。
実行によって、テストが成功したり失敗したりする不安定なテストは、原因解析に時間がかかり、自動テストの運用に悪影響が及びます。

Espressoの自動同期機能

Espressoには自動同期機能があり、画面更新の完了を自動的に待ち合わせてくれます。
次の条件をすべて満たしたときに画面更新が完了したと判断します。

 逆に、下記のケースでは自動同期機能はうまく動作しません。

これらの場合、開発者自身が自動同期機能以外を使う必要があります。

以下のようなフローで、対応法を考えると良いと思います。

  1. Espressoが提供する自動同期機能が使えるなら使う
  2. 自動同期機能が使えないとき、すでに提供されているIdlingResourceが入れられそうなところがないかを調査する
  3. idlingResourceの適用が難しいとき、うまく動作しない箇所に明示的な待ち合わせ処理を書いていく

次ページ以降で、自動同期機能以外の待ち合わせ処理を紹介していきます。

ここでは、UI Automatorを使った明示的な待ち合わせ処理を紹介します。

基本的な使い方は次のようになります。

val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

val waitSuccess = uiDevice.wait(待ち合わせ条件, タイムアウト値)
assertTrue(waitSuccess);

uiDeviceは、一度setUp()等でインスタンスを取得したらそれを使い回すことができます。
待ち合わせ条件は、Untilクラスのメソッドのうち、戻り値の型がSearchCondition<R>型のものです。
通常は、次の2つのメソッドがあることを覚えておけば十分でしょう。

BySelector型の引数にはByクラスに定義されているstaticメソッドが利用できます。
下の例では、CompletedであるViewが現れるまで待つことを表現しています。

val waitSuccess = uiDevice.wait(Until.hasObject(By.text("Completed")), 5000L
assertTrue(waitSuccess)

待ち合わせ条件として、複数の条件をANDでつなげたものを指定したいときは下のようにします。
リソースIDがR.id.textViewかつ表示テキストがCompletedであるViewが現れるまで待つことを表現しています。
リソースIDを検索条件に指定するときは、By.res()を使用します。res()の引数は文字列型です。

val cond = Until.hasObject(By.res("com.example.sample:id/textview").text("Completed"))
val waitSuccess = uiDevice.wait(cond, 5000L)
assertTrue(waitSuccess)

リソースIDの文字列表記は次のような規則を持っています。

<テスト対象アプリの applicationId>:id/<リソースIDのフィールド名>

この規則にしたがって毎回文字列に変換することは大変なので、ユーティリティメソッドを定義すると楽になります。

fun toResourceName(resId: Int): String {
    return ApplicationProvider
            .getApplicationContext<Application>()
            .resources
            .getResourceName(resId)
}

演習用画面

Plant listで植物を選択した際に遷移する、Plant Detail画面がこの演習の対象画面です。

非同期待ち合わせ演習用画面

補足:DataBinding用のIdlingResource

このテストコードでは、DataBindingIdlingResourceを使用しています。そのActivity/Fragment内のData bindingがアイドル状態かビジー状態かを判断します。

演習内容とは関係ないため、詳細は割愛します。

演習

androidTest配下のcom.google.samples.apps.sunflower.PlantDetailFragmentTestを開いてください。
テストメソッドaddPlantToGarden_UIAutomatorにTODOと書かれている部分があるので、そこを埋めていってください。
また、前のセクションで紹介したユーティリティメソッドtoResourceNameが実装済みですのでご活用ください。

解答例

androidTest配下のcom.google.samples.apps.sunflower.example.ExamplePracticeFragmentTestに解答例をコミットしているので、適宜ご参照ください。

Espressoの自動同期機能が画面更新が完了したと判断する条件は次のとおりでした。

3つめのIdlingResourceは、Espressoが提供する自動同期機能をカスタマイズするためのインターフェースです。

このIdlingResourceを活用することで、開発者が自動同期機能が有効になるタイミングをコントロールすることができるようになります。

IdlingResourceの仕組み

EspressoではIdlingResourceを使ってどのように待ち合わせをしているのでしょうか。

IdlingResourceは2つの状態を持っており、それによってEspressoが次の操作を実行するか待機するかを判断します。

IdlingResourceの状態

Espressoの判定

ビジー状態

次の操作を実行せずに待機する

アイドル状態

次の操作を実行してテストを継続する

図にすると次のようなイメージです。

IdlingResourceイメージ

CountingIdlingResource

Espressoでは、いくつかのIdlingResourceの実装を提供していますが、代表的なものは、CountingIdlingResourceクラスです。
CountingIdlingResourceは同時に動いている非同期処理の数をカウンタで管理するIdlingResource実装です。

プログラマは次のルールにしたがってCountingIdlingResourceクラスのメソッドを呼び出す必要があります。

そして、カウンタが0のときをアイドル状態、0より大きい時をビジー状態とします。

そのため、管理したい非同期処理の開始・終了をフックできるのであれば、このCountingIdlingResourceを使って簡単に待ち合わせを実現できます。

次のコードは、Coroutineの処理の開始と終了にCountingIdlingResourceを組み込む例です。

fun addPlantToGarden() {

    val countingIdlingResource = CountingIdlingResource("CountingIdlingResource")

   // 処理の開始時にカウンタを1増加 = ビジー状態になる
    countingIdlingResource.increment()

    val job = viewModelScope.launch {
        repository.addGardenPlanting(plantId)
    }

    // Coroutineのjobの終了はinvokeOnCompletionでフックできるので、そこでdecrementをする
    job.invokeOnCompletion {
        // 処理の開始時にカウンタが1減少 = 0になり、アイドル状態になる
        countingIdlingResource.decrement()
    }
}

サードパーティ製のIdlingResource

RxJavaのような、独自の非同期処理機構を提供するライブラリを使っている場合は、ライブラリに応じてIdlingResourceの実装が提供されていることがあります。
その場合は、迷わず導入して恩恵に授かりましょう。

サードパーティ製のIdlingResourceには次のようなものがあります。

IdlingResourceの登録・解除

監視したいIdlingResourceを追加するには、EspressoのIdlingRegistryに登録・解除する必要があります。

IdlingRegistryクラスのregister()メソッドで登録し、unregister()メソッドで解除してください。

@Test
fun test() {

    // CountingIdlingResource(後述)を登録する例
    val idlingResource = CountingIdlingResource("Idling Resource")

    // idlingResourceを登録する
    IdlingRegistry.getInstance().register(idlingResource)

    // Fragmentを起動する
    val scenario = launchFragmentInContainer<ExampleFragment>()

    /*
        テスト実装
    */

    // idlingResourceの登録を解除する
    IdlingRegistry.getInstance().unregister(idlingResource)

}

ActivityのonCreate()で非同期処理が始まる場合

前述のActivitySenarioRuleを使用した場合、テスト開始時にはすでにActivityはonResume()まで完了した状態になっています。
そのため、onCreate()で非同期処理が始まるようなケースには対応できません。

onCreate()の時点でIdlingResourceの登録が済んだ状態にするためには、下の例のように、
IdlingResourceの登録をして、手動でActivityを起動させる必要があります。

@Test
fun test() {

    // idlingResourceを登録する
    IdlingRegistry.getInstance().register(idlingResource)

    // Activityを起動する
    // useを使ってblockを出たら自動的にcloseするようにする
    launchActivity<ExampleActivity>().use {
        
        /*
            このブロックの中でActivityがRESUMED状態
            テスト実装をここに書く
        */
    }

    // idlingResourceの登録を解除する
    IdlingRegistry.getInstance().unregister(idlingResource)
}

ActivitySenarioはテスト終了後にcloseを呼ぶことが推奨されています。
ActivitySenarioRuleではRuleの中で自動的に呼んでくれますが、ActivityScenarioを直接使う場合は自身でcloseを呼びます。
useをつかうようにすると、closeの呼び忘れが防げるのでおすすめです。

Coroutineを使用している場合、IdlingResource実装が提供されていないため開発者が待ち合わせの仕組みを用意する必要があります。

このセクションでは、いくつかの待ち合わせの手段を紹介します。

プロダクトコードにIdlingResourceを埋め込む

前のセクションでCoroutineの処理の開始と終了にあわせて、CountingIdlingResourceのincrementとdecrementをする例を紹介しました。
このようにプロダクトコード側でCountingIdlingResourceの埋め込みができれば、簡単にCoroutineの待ち合わせを実現することができます。

その場合、IdlingResourceをシングルトンにするか、IdlingResourceのインスタンスをプロダクトコードの外から渡せるようにします。そうすることで、プロダクトコードで参照しているIdlingResourceのインスタンスをテストコードから登録・解除できるようにします。

補足
Espressoのドキュメントでは、プロダクトコードにIdlingResourceを追加することを推奨しています。ですので、プロダクトコードに導入することも視野に入れつつ、プロダクトに適した方法を選択するのがよいでしょう。

IdlingThreadPoolExecutorを使用する

EspressoはIdlingThreadPoolExecutorを提供しています。これは、既にIdlingResourceのincrementとdecrementが組み込まれているThreadPoolExecutorです。
テストコード側で非同期の処理をこのExecutorに差し替えることができれば、プロダクトコードでIdlingResourceを呼び出す必要がなくなります。

androidTest配下のcom.google.samples.apps.sunflower.PlantDetailFragmentTestaddPlantToGarden_idlingThreadPoolExecutorメソッドがあります。これがIdlingThreadPoolExecutorを使った待ち合わせの演習になっていますので、興味のある方は試してみてください。

ポイントは以下です。

非同期処理をテストのスコープから外し、IntegrationTestとして実装する

非同期処理など通信を行っているクラスをテストダブルに差し替え、実際には非同期処理を行わないようにすることもできます。
この場合のUIテストはE2Eではなく、Integration Test(or Unit Test)になります。
Fidelityは下がりますが、テストの安定性・速度が向上します。また、テストダブルをつかうことによって、任意の条件のテストが簡単にできるようになります。

このようなテストをしたい場合、テストコードでActivityやFragmentの依存を差し替えられるようにする必要があります。
DIライブラリやServiceLocator、FragmentFactoryの導入を検討してください。

Espressoを使ったUIテストの書き方を学びました。
今後のあなたの仕事に役立てていただけるのであれば幸いです。

このCodelabについて

このCodelabはDeNAのSWETグループが作成しました。
swet logo

もしご不明点や間違い等あれば、トップページの「このページについて」に記載されている手順でIssueを起票していただければと思います。

このCodelabの一部は、関係者の許諾の元、書籍「Androidテスト全書」の5章「UI テスト(Espresso編)」の内容を一部流用しています。

「Androidテスト全書」にはここで紹介する内容のほか、役立つ情報が満載です。気になる方は読んでみてください。

Androidテスト全書

Androidテスト全書

  • 著者:白山 文彦,外山 純生,平田 敏之,菊池 紘,堀江 亮介,
  • 製本版,電子版
  • PEAKSで購入する

フィードバックのお願い

最後に、お手数ですが、次ページで本Codelabに対するフィードバックをいただけますと嬉しいです。
今後の私たちの活動に活かしていきたいと思います。