このCodelabでは、Espressoを使ったAndroidのUIテストの書き方を学びます。
Settings > System > Language & input > Language
をEnglish (United States)
にするSetting > System > About emulator device > Build number
を何回もタップし、Developer Optionsメニューを表示させるSettings > System > Advanced > Developer Options
を表示し、以下の設定を全てOFFにする$ git clone git@github.com:DeNA/codelabs.git
codelabs/sources/android-ui-tests-espresso
ディレクトリに題材アプリがありますので、それをAndroid Studioで開きますGoogleがAndroid Jetpackのサンプルアプリとして公開しているAndroid Sunflowerの2019年5月18日時点のソースコードをベースに、少し改修を加えたものを題材として使用します。改修内容としては、JavaからKotlinへの書き換えなどを行いました。
主要な機能は以下のとおりです。ビルドをして確認してみてください。
EspessoでUIテストを書くために、まずテスト対象の画面を起動する必要があります。
ActivityとFragment、それぞれ起動するためのAPIを紹介します。
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も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にアタッチされて起動します。
FragmentScenaioは次の引数をとります。
引数名 | 引数型 | 概要 |
fragmentArgs | Bundle | Fragment起動時のargument |
themeResId | int | FragmentのUIテーマ |
factory | FragmentFractory | Fragmentの生成Factory |
ActivityとFragmentのテストについて、さらに詳しい情報は公式ドキュメントを御覧ください。
Espressoを使ってテストコードを書くときの基本構造について学びます。
すでに精通している場合は、次ページへと進んでください。
Espressoを使ったテストコードは、原則として下に示した基本構造を繰り返して書くことになります。
onView(ViewMatcher).perform(ViewAction).check(ViewAssertion)
この基本構造の処理内容は下のようになります。
条件に合致するViewが複数存在する場合や、合致するViewが見つからなかった場合は例外が発生します。
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は名前がややこしいですが、下記の図のような関係になっています。
子孫を条件として使う例として、同じカスタムViewを複数持つ画面があったとします。
このような画面で、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))))
performの引数、ViewActionとしてよく使うメソッドを下にまとめました。
メソッドはandroidx.test.espresso.action.ViewActions
に定義されており、返り値の型はいずれもViewAction
です。
メソッド名 | 引数の型 | メソッドの概要 |
click | 引数なし | 目的のViewをクリックする |
replaceText | String | 目的のEditTextに、引数に指定されたテキストをセットする。もともと設定されていたテキストはクリアする |
scrollTo | 引数なし | 目的のViewが画面内に現れるまでスクロールする |
Actionは複数指定することができ、第一引数から順に実行されます。
以下のようにperformを実装した場合、目的のViewまでスクロールした後にクリックをします。
perform(scrollTo(), click())
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")))
check
やperform
は省略可能です。例えば、「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の公式サイトでも開発に便利なチートシートを公開しています。
こちらもあわせてご覧ください。
操作対象のViewをみつけるためにViewMatcherで検索条件を指定することを学びました。
この検索条件はたったひとつのViewにマッチするように絞り込む必要があります。
そのために、Viewの階層構造や目的のViewがもつ属性(リソースIDなど)を知る必要があります。
そこで活躍するのがレイアウトインスペクタです。
代表的なレイアウトインスペクタとして次の3つがあります。
今回は、Android Studio付属のLayout Inspectorとuiautomatorviewerの紹介をします。
Android Studio付属のLayout Inspectorは原則としてデバッグ可能なアプリの画面しか調査できません。
そのかわり、非常に多くのView属性を調査できます。特に画面上に表示されていないViewについて、本当に存在しないのか、visibility属性がgoneの不可視のViewが存在していのか区別できるのはこのツールだけです。
上の手順を踏むと、下のような表示がされます。
真ん中にエミュレータで開いている画面が表示されますので、そこから調べたい要素をクリックしてください。
そうすると、左のView Treeにはクリックした要素が階層構造のどこにあるのかが示され、右のProperties Tableには要素の属性の一覧が示されます。
例えば、リソースIDが知りたければ、Properties Tableの「properties」->「mID」を見ると良いでしょう。
uiautomatorviewerも任意のアプリの画面を調査できます。テストを書くという目的であればこちらのツールでも十分な場合が多いです。
ただ、表示されていないViewは取得できないなど、取得できる情報には限りがあるので、注意が必要です。
$ANDROID_SDK_ROOT(or $ANDROID_HOME)/tools/bin/uiautomatorviewer &
で起動する上の手順を踏むと、下のような表示がされます。
左側のペインで調べたい要素をクリックすると、右側のペインに要素の情報が表示されます。
Navigation Drawerから「練習用」を選択したときの画面が今回の演習でのテスト対象画面です。
sharedTest
配下のcom.google.samples.apps.sunflower.PracticeFragmentTest
を開いてください。テストコード内にTODOと書かれている部分があるので、そこを埋めていってください。
Fragmentの起動までは実装されているので、その後のEspressoのコードを実装してください。
この演習で作成したテストは、Local Testとしても、Instrumentation Testとしても動作します。
Run > Edit Configurations
で、それぞれの実行コンフィギュレーションを作成して実行してみてください。
module
に「app」を指定するTest
に「Class」を指定するClass
に「PracticeFragmentTest」を指定するEdit Configurationsダイアログは次のようになります。
Use class path of module
に「app」を指定するClass
に「PracticeFragmentTest」を指定するEdit Configurationsダイアログは次のようになります。
sharedTest
配下のcom.google.samples.apps.sunflower.example.ExamplePracticeFragmentTest
に解答例をコミットしているので、適宜ご参照ください。
その他ScenarioAPIの使い方の紹介も含まれているので、興味があるかたはどうぞ参考にしてください。
RecyclerViewはAndroidアプリでリスト画面を実装する際に使用されるUIコンポーネントです。
このセクションでは、Espressoで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のインターフェイス定義を見てみましょう。
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をクリックできそうです。
findViewById(int)
を使って見付けるclick()
と同じ処理を行うその実装を書き下すと、次のようになります。
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条件)で一意にマッチさせることができそうです。
n
番目のアイテムビュー」を持つR.id.checkBox
である以降では「position
番目のアイテムビュー」にマッチするViewMatcherを返すメソッドwithItemViewAtPosition(recyclerView: Matcher<View>, position: Int): Matcher<View>
を自作してみましょう。
このメソッドを使えば、「n
番目のアイテムビューのチェックボックス」にマッチするViewMatcherは次のように構築できます。
allOf(
withId(R.id.checkBox),
isDescendantOfA(withItemViewAtPosition(withId(R.id.recyclerView), n)))
前述のwithItemViewAtPosition(recyclerView: Matcher<View>, position: Int)
メソッドは、次の条件を全て満たすアイテムビューにマッチするViewMatcherを返します。
recyclerView
にマッチするRecyclerView
に所属していることposition
番目であることカスタム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)
メソッドです。
このメソッドを、以下の仕様を満たすように実装します。
view
にマッチしたときはtrue
を返すfalse
を返すEspressoは、画面上のViewひとつひとつについて、それを引数にこのメソッドを呼び出していきます(Visitorパターン)。そしてtrue
を返したViewを「マッチしたView」とみなすのです。
今回はアイテムビューとマッチさせたいのですから、引数view
に前述の2つの条件を満たすアイテムビューが渡されたときだけtrue
を返すように実装します。
改めて2つの条件を確認してみましょう。
recyclerView
にマッチするRecyclerView
に所属していることposition
番目であることそれを踏まえて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」を選択したときの画面が今回の演習でのテスト対象画面です。
androidTest
配下のcom.google.samples.apps.sunflower.PlantListRecyclerViewTest
を開いてください。テストコード内にTODOと書かれている部分があるので、そこを埋めていってください。
androidTest
配下のcom.google.samples.apps.sunflower.example.ExamplePlantListRecyclerViewTest
に解答例をコミットしているので、適宜ご参照ください。
UIテストで直面しがちな問題のひとつに画面更新の完了を待たずに検証を行ってしまうというものがあります。
例えば、つぎのようなテストケースを考えてみます。
手順2でログイン完了するまでの時間は一定ではなく、1秒で終わることもあれば、10秒かかることもあります。
10秒かかるとき、テストは失敗します。1秒で終わるとき、テストは失敗しませんが、2秒余分にテスト実行時間が長くなります。
実行によって、テストが成功したり失敗したりする不安定なテストは、原因解析に時間がかかり、自動テストの運用に悪影響が及びます。
Espressoには自動同期機能があり、画面更新の完了を自動的に待ち合わせてくれます。
次の条件をすべて満たしたときに画面更新が完了したと判断します。
逆に、下記のケースでは自動同期機能はうまく動作しません。
これらの場合、開発者自身が自動同期機能以外を使う必要があります。
以下のようなフローで、対応法を考えると良いと思います。
次ページ以降で、自動同期機能以外の待ち合わせ処理を紹介していきます。
ここでは、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画面がこの演習の対象画面です。
このテストコードでは、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を活用することで、開発者が自動同期機能が有効になるタイミングをコントロールすることができるようになります。
EspressoではIdlingResourceを使ってどのように待ち合わせをしているのでしょうか。
IdlingResourceは2つの状態を持っており、それによってEspressoが次の操作を実行するか待機するかを判断します。
IdlingResourceの状態 | Espressoの判定 |
ビジー状態 | 次の操作を実行せずに待機する |
アイドル状態 | 次の操作を実行してテストを継続する |
図にすると次のようなイメージです。
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()
}
}
RxJavaのような、独自の非同期処理機構を提供するライブラリを使っている場合は、ライブラリに応じて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)
}
前述の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実装が提供されていないため開発者が待ち合わせの仕組みを用意する必要があります。
このセクションでは、いくつかの待ち合わせの手段を紹介します。
前のセクションでCoroutineの処理の開始と終了にあわせて、CountingIdlingResourceのincrementとdecrementをする例を紹介しました。
このようにプロダクトコード側でCountingIdlingResourceの埋め込みができれば、簡単にCoroutineの待ち合わせを実現することができます。
その場合、IdlingResourceをシングルトンにするか、IdlingResourceのインスタンスをプロダクトコードの外から渡せるようにします。そうすることで、プロダクトコードで参照しているIdlingResourceのインスタンスをテストコードから登録・解除できるようにします。
補足
Espressoのドキュメントでは、プロダクトコードにIdlingResourceを追加することを推奨しています。ですので、プロダクトコードに導入することも視野に入れつつ、プロダクトに適した方法を選択するのがよいでしょう。
EspressoはIdlingThreadPoolExecutor
を提供しています。これは、既にIdlingResourceのincrementとdecrementが組み込まれているThreadPoolExecutorです。
テストコード側で非同期の処理をこのExecutorに差し替えることができれば、プロダクトコードでIdlingResourceを呼び出す必要がなくなります。
androidTest
配下のcom.google.samples.apps.sunflower.PlantDetailFragmentTest
にaddPlantToGarden_idlingThreadPoolExecutor
メソッドがあります。これがIdlingThreadPoolExecutorを使った待ち合わせの演習になっていますので、興味のある方は試してみてください。
ポイントは以下です。
asCoroutineDispatcher()
でCoroutineDispatcherへの変換が可能非同期処理など通信を行っているクラスをテストダブルに差し替え、実際には非同期処理を行わないようにすることもできます。
この場合のUIテストはE2Eではなく、Integration Test(or Unit Test)になります。
Fidelityは下がりますが、テストの安定性・速度が向上します。また、テストダブルをつかうことによって、任意の条件のテストが簡単にできるようになります。
このようなテストをしたい場合、テストコードでActivityやFragmentの依存を差し替えられるようにする必要があります。
DIライブラリやServiceLocator、FragmentFactoryの導入を検討してください。
Espressoを使ったUIテストの書き方を学びました。
今後のあなたの仕事に役立てていただけるのであれば幸いです。
このCodelabはDeNAのSWETグループが作成しました。
もしご不明点や間違い等あれば、トップページの「このページについて」に記載されている手順でIssueを起票していただければと思います。
このCodelabの一部は、関係者の許諾の元、書籍「Androidテスト全書」の5章「UI テスト(Espresso編)」の内容を一部流用しています。
「Androidテスト全書」にはここで紹介する内容のほか、役立つ情報が満載です。気になる方は読んでみてください。
最後に、お手数ですが、次ページで本Codelabに対するフィードバックをいただけますと嬉しいです。
今後の私たちの活動に活かしていきたいと思います。