writing-espresso-tests について
Android Views の Activity、Fragment、Dialog を対象とする Espresso のインストルメンテーションテスト向けスキルガイドです。ビューの検索、操作、アサーション、同期時の注意点を扱い、Compose など別のテスト手法との使い分けも示します。
主な機能
- 01ビュー検索、操作、アサーション
- 02ダイアログとポップアップの Root 選択
- 03RecyclerView と AdapterView の操作
- 04Intent のテストとスタブ化
- 05非同期処理用 IdlingResource の登録
活用シーン
- 01Android Views の操作テスト
- 02ビューの未検出や複数一致の調査
- 03ダイアログボタンとポップアップメニューのテスト
- 04Espresso が追跡できない非同期処理の同期
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill writing-espresso-testsインストール後、Claude Code で/writing-espresso-testsを入力して読み込まれたか確認します。
詳細
Espresso テストの作成 — Android View のアイドル状態後に操作するモデル
Espresso は、Android の View(Compose ではありません。Compose には compose/ スキルセットを使用してください。Compose と View の相互運用については ../../../compose/interop/testing-with-espresso-interop/SKILL.md を参照してください)における、標準的な計測時 UI テストフレームワークです。各 onView(...).perform(...).check(...) 呼び出しでは、内部的にメインルーパーがアイドル状態になるまで処理し、一致する View をちょうど 1 つ見つけて操作を実行し、確認の前に再びアイドル状態になるまで処理します。このスキルでは、マッチャー/アクション/アサーションの全カタログ、RootMatchers.DEFAULT の落とし穴、バージョンごとの注意点を網羅しています。
このスキルを使用する場面
- Android View(XML レイアウト、AppCompat、Material View コンポーネント)で UI が構築された Activity、Fragment、または Dialog をテストしている場合。
- テストが
NoMatchingViewException、AmbiguousViewMatcherException、またはPerformExceptionで失敗する場合。 AlertDialogのボタンをクリックしても何も起きない場合 —RootMatchers.DEFAULTはダイアログを除外します。- カメラ、連絡先、共有のフローで
Intent.startActivityForResultをスタブしたい場合。 - Espresso では検出できない長時間実行タスクのために
IdlingResourceを登録したい場合。 RecyclerViewの操作をデバッグしている場合。BottomNavigationViewActionsに言及しており、Espresso 3.7.0 contrib ではクラスパスに含まれていないことに驚いている場合。
このスキルを使用しない場面
- UI が Jetpack Compose の場合 —
compose/スキル(../../../compose/finders/finding-nodes-by-tag-text-content/SKILL.mdなど)を使用してください。Compose と View が混在するホストについては、../../../compose/interop/testing-with-espresso-interop/SKILL.mdを参照してください。 - テストがアプリの境界(設定、電話アプリ、システム UI)をまたぐ場合 — Espresso では
InjectEventSecurityExceptionが発生します。../../uiautomator/cross-app-tests-with-uiautomator/SKILL.mdを使用してください。 - ランナーのスタックがまだ設定されていない場合 —
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.mdから始めてください。 - Activity がまだ起動されていない場合 —
../../scenarios/launching-activities-with-activityscenario/SKILL.mdと組み合わせてください。
前提条件
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.mdのランナーのスタック。- Espresso のアーティファクト(
docs/CORPUS.mdH.1 による 3.7.0 GA):
dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.7.0") // RecyclerViewActions, PickerActions
androidTestImplementation("androidx.test.espresso:espresso-intents:3.7.0") // Intents.intended/intending
androidTestImplementation("androidx.test.espresso:espresso-accessibility:3.7.0")
androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0") // WebView
implementation("androidx.test.espresso:espresso-idling-resource:3.7.0") // for production code that exposes IdlingResources
}
android.testOptions.animationsDisabled = true // hermetic
ActivityScenario/ActivityScenarioRule/FragmentScenarioを使用して起動したActivity(または Fragment)。
ワークフロー
- 1.
onView(matcher)で View を見つけます。 マッチャーは Hamcrest のMatcher<View>です。識別情報を優先するマッチャー(最も推奨):withId(R.id.button)、withTagKey(int)、withResourceName(String)。テキスト/説明を使った代替候補:withText、withContentDescription、withHint。ソース:androidx.test.espresso.matcher.ViewMatchers(79~1000 行以上、R3 の 180~280 行):
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.*
import org.hamcrest.Matchers.allOf
onView(withId(R.id.submit_button))
onView(allOf(withText("Submit"), isEnabled()))
onView(withContentDescription("Open menu"))
onView(allOf(withId(R.id.subtitle), hasDescendant(withText("Hello"))))
-
2. 状態を考慮したマッチャーを組み合わせます。
ViewMatchersの一般的な状態マッチャー:isDisplayed、isCompletelyDisplayed、isDisplayingAtLeast(percent)、isEnabled/isNotEnabled、isFocused、isChecked/isNotChecked、isSelected、withEffectiveVisibility(Visibility.VISIBLE|INVISIBLE|GONE)、withAlpha(float)。階層マッチャー:withParent、hasDescendant、withChild、isRoot、withClassName、isAssignableFrom(Class)。レイアウト相対マッチャー:hasSibling、isDescendantOfA。ソース:R3 の 180~280 行。 -
3.
.perform(action)でViewActionsを使って操作します。androidx.test.espresso.action.ViewActionsから使用します。
import androidx.test.espresso.action.ViewActions.*
onView(withId(R.id.email)).perform(typeText("[email protected]"), closeSoftKeyboard())
onView(withId(R.id.submit_button)).perform(click())
onView(withId(R.id.input)).perform(replaceText("new"), pressImeActionButton())
onView(withId(R.id.list)).perform(swipeUp())
onView(withId(R.id.scroller)).perform(scrollTo()) // requires single-direction ScrollView
クリック系:click、longClick、doubleClick。テキスト入力:typeText、replaceText、clearText、pressKey、pressImeActionButton、closeSoftKeyboard。ジェスチャー:swipeUp、swipeDown、swipeLeft、swipeRight、scrollTo。ソース:androidx.test.espresso.action.ViewActions。
- 4.
.check(...)でViewAssertionsを使ってアサートします。androidx.test.espresso.assertion.ViewAssertionsから使用します。
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*
onView(withId(R.id.title)).check(matches(withText("Welcome")))
onView(withId(R.id.banner)).check(matches(isDisplayed()))
onView(withId(R.id.banner)).check(doesNotExist())
onView(withId(R.id.list)).check(selectedDescendantsMatch(withId(R.id.row), isCompletelyDisplayed()))
matches(matcher) は、解決された View がマッチャーに一致すると成功します。doesNotExist() は、マッチャーに一致する View が 0 個に解決された場合に成功します(View が 1 つ見つかったものの非表示の場合には成功しません。その場合は matches(not(isDisplayed())) を使用してください)。ソース:R3 の 290~311 行。
- 5. 暗黙のアイドル状態を認識する。 すべての
onView(...).perform(...)および.check(...)呼び出しでは、内部的に次の処理が行われます。- dagger で注入された
mainThreadExecutorを介して、メインスレッドでアクションを送信します。 - 次の条件が満たされるまで
uiController.loopMainThreadUntilIdle()を呼び出します。メインのMessageQueueが空であること、IdlingRegistryに登録されたすべてのIdlingResourceがisIdleNow == trueを返すこと、登録されたメイン以外のすべてのLooperがアイドル状態であること、そして AsyncTask スレッドプールがアイドル状態であること。 - ビューを解決します(現在の
Rootの階層に対してviewMatcherを適用します)。0 件の場合はNoMatchingViewException、2 件以上の場合はAmbiguousViewMatcherExceptionが発生します。 viewAction.getConstraints().matches(view)を確認し、false の場合はPerformExceptionをスローします。viewAction.perform(uiController, view)(またはviewAssertion.check(view, null))を呼び出します。- 戻る前に、メインスレッドが再びアイドル状態になるまでループします。
- dagger で注入された
これは アイドル状態にしてからアクションを実行する モデルです。ローカルでは動作するのに CI では失敗するテストでは、Looper によって駆動されない非同期処理(カスタムの ExecutorService、OkHttp のディスパッチャーなど)に対する IdlingResource が不足していることがよくあります。出典:R3 の 165~178 行、811~820 行。
- 6. ダイアログやポップアップでは、
inRoot(...)でルートを指定します。RootMatchers.DEFAULTはダイアログ、ポップアップ、トーストを対象外とし、「フォーカスされたアクティビティウィンドウ」に一致します。AlertDialogのボタンの場合:
import androidx.test.espresso.matcher.RootMatchers.*
onView(withText("Submit")).inRoot(isDialog()).perform(click())
onView(withText("Copy")).inRoot(isPlatformPopup()).perform(click()) // overflow menu, popup window
onView(withText("Saved")).inRoot(isPlatformPopup()).check(matches(isDisplayed())) // toast
R3 の 311 行によると、RootMatchers.DEFAULT は allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable())) です。これが、一般的なダイアログのボタンクリックが何も通知せずに対象から外れる理由です。
- 7.
RecyclerViewには contrib のRecyclerViewActionsを使います。RecyclerViewの行に対してonView(...).perform(...)を直接実行しても、行が画面外にあって見つかるビューが存在しない場合があるため、動作しません。
import androidx.test.espresso.contrib.RecyclerViewActions.*
onView(withId(R.id.list))
.perform(scrollToPosition<RecyclerView.ViewHolder>(20))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(3, click()))
onView(withId(R.id.list))
.perform(scrollTo<RecyclerView.ViewHolder>(hasDescendant(withText("Pikachu"))))
onView(withId(R.id.list))
.perform(actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText("Pikachu")), click()))
すべての RecyclerViewActions では、isAssignableFrom(RecyclerView.class) AND isDisplayed() 制約が必要です(R3 の 428 行)。
- 8.
AdapterView(ListView、Spinner)には、onView(...)ではなくonData(...)を使います。
import androidx.test.espresso.Espresso.onData
import org.hamcrest.Matchers.allOf
onData(allOf(`is`(instanceOf(String::class.java)), `is`("Item 5")))
.inAdapterView(withId(R.id.list))
.perform(click())
onData は、ビューを解決する前にアダプターをスクロールして項目を画面上に表示します。
- 9.
Intentsでインテントをスタブ化して検証します。@Beforeで初期化し、@Afterで解放します。IntentsRule(3.5 以降)を使えば、両方の処理を行えます。
import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.intent.Intents.*
import androidx.test.espresso.intent.matcher.IntentMatchers.*
@get:Rule val intentsRule = IntentsRule()
@Test
fun openCameraStub() {
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
onView(withId(R.id.take_photo)).perform(click())
intended(allOf(
hasAction(MediaStore.ACTION_IMAGE_CAPTURE),
hasFlag(Intent.FLAG_GRANT_READ_URI_PERMISSION),
))
}
docs/CORPUS.md H.7 によると、IntentsTestRule は @Deprecated です。IntentsRule を使うか、Intents.init()/Intents.release() を手動で呼び出してください。
- 10. Looper によって駆動されない非同期処理には
IdlingResourceを登録します。 Espresso はデフォルトでメイン Looper とAsyncTaskプールを監視します。OkHttp、カスタムのExecutorService、センサーパイプラインでは、アイドル状態を通知する必要があります。
class CountingIdlingResource(private val name: String) : IdlingResource {
private val counter = AtomicInteger(0)
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = name
override fun isIdleNow() = counter.get() == 0
override fun registerIdleTransitionCallback(cb: IdlingResource.ResourceCallback) { callback = cb }
fun increment() { counter.incrementAndGet() }
fun decrement() { if (counter.decrementAndGet() == 0) callback?.onTransitionToIdle() }
}
@Before fun setUp() = IdlingRegistry.getInstance().register(myIdler)
@After fun tearDown() = IdlingRegistry.getInstance().unregister(myIdler)
// Master policy default is 60 seconds; setIdlingResourceTimeout default is 26 seconds.
// For debugger sessions, opt out of timeouts entirely with
// IdlingPolicies.setMasterPolicyTimeoutWhenDebuggerAttached(false)
// — there is no "-1 = infinite" magic value.
IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS)
IdlingPolicies.setIdlingResourceTimeout(45, TimeUnit.SECONDS)
ネットワーク呼び出しや非同期処理を、プロダクションコードからの increment()/decrement() でラップします(:espresso-idling-resource アーティファクトは androidTestImplementation ではなく implementation に指定します)。
-
11. エラーを正確に読み取ります。
NoMatchingViewException— 現在のルートで一致するビューが 0 件でした。ビューが現在表示されていることを確認してください。ダイアログ内にある場合は、.inRoot(isDialog())を追加します。AmbiguousViewMatcherException— 2 件以上のビューが一致しました。allOf(...)で条件を絞り込みます。PerformException— アクションの制約に失敗しました(例:click()にはisDisplayingAtLeast(90)が必要です)。例外メッセージには制約の説明が含まれます。InjectEventSecurityException— イベントが別のウィンドウにまたがろうとしました。テストがシステム UI に到達しています。代わりに UiAutomator を使ってください。
パターン
パターン:間違いと正解 — ダイアログボタンのクリック
// WRONG
onView(withText("Submit")).perform(click())
// WRONG because: RootMatchers.DEFAULT (line 51 of RootMatchers.java) excludes dialogs.
// The matcher does not see the dialog window. Result: NoMatchingViewException ("No views in
// hierarchy found matching: with text Submit"), even though the dialog is clearly on screen.
// RIGHT
import androidx.test.espresso.matcher.RootMatchers.isDialog
onView(withText("Submit")).inRoot(isDialog()).perform(click())
ポップアップメニュー(isPlatformPopup())とトーストにも同じことが当てはまります。
パターン:間違いと正解 — BottomNavigationView の操作(Espresso 3.7.0 contrib)
// WRONG
import androidx.test.espresso.contrib.BottomNavigationViewActions
onView(withId(R.id.bottom_nav)).perform(BottomNavigationViewActions.selectItem(R.id.tab_profile))
// WRONG because: BottomNavigationViewActions does NOT exist in espresso-contrib 3.7.0.
// The class was historically documented but never shipped in 3.7.0's contrib jar (R3 lines 91-93).
// Compile fails with "unresolved reference".
// RIGHT — click the underlying menu item view directly
import com.google.android.material.bottomnavigation.BottomNavigationView
import org.hamcrest.Matchers.allOf
onView(allOf(
withId(R.id.tab_profile),
isDescendantOfA(isAssignableFrom(BottomNavigationView::class.java)),
)).perform(click())
パターン:間違いと正解 — PickerActions.setDate の月の規則
// WRONG
import androidx.test.espresso.contrib.PickerActions
onView(withClassName(equalTo(DatePicker::class.java.name)))
.perform(PickerActions.setDate(2026, 4, 15)) // intends "May 15, 2026"
// WRONG because: PickerActions.setDate uses 1-12 month convention (it subtracts 1 internally
// before calling DatePicker.updateDate which uses 0-11). Passing 4 sets April, not May.
// Source: R3 line 460, PickerActions.java line 49.
// RIGHT — May = 5
onView(withClassName(equalTo(DatePicker::class.java.name)))
.perform(PickerActions.setDate(2026, 5, 15))
パターン:間違いと正解 — アニメーションの待機に Thread.sleep を使う
// WRONG
onView(withId(R.id.fab)).perform(click())
Thread.sleep(500) // wait for the FAB exit animation
onView(withId(R.id.fab)).check(doesNotExist())
// WRONG because: Thread.sleep is a smell. Espresso's idle pump already waits for animations
// driven through the main looper; if it doesn't, register an IdlingResource for the offending
// async path. See `docs/CORPUS.md` D.7 — "Thread.sleep is a smell".
// RIGHT — disable animations OR register an IdlingResource
android.testOptions.animationsDisabled = true // for property animators driven via Choreographer
// And/or, for non-Looper waits:
IdlingPolicies.setMasterPolicyTimeout(45, TimeUnit.SECONDS)
IdlingRegistry.getInstance().register(myAnimationIdler)
パターン:画面外の行まで RecyclerView をスクロールしてからクリックする
import androidx.test.espresso.contrib.RecyclerViewActions.*
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
onView(withId(R.id.list))
.perform(scrollTo<RecyclerView.ViewHolder>(hasDescendant(withText("Snorlax"))))
onView(withId(R.id.list))
.perform(actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText("Snorlax")), click()))
scrollTo と actionOnItem は、画面上の Matcher<View> ではなく、HOLDER マッチャー(基になる RecyclerView.ViewHolder の itemView に一致)を使用します。
必須ルール
AlertDialogのコンテンツビューを対象とするマッチャーには.inRoot(isDialog())を、ポップアップメニューやトーストには.inRoot(isPlatformPopup())を必ず追加してください。RootMatchers.DEFAULTはこれらを除外します。withTextよりもwithId/withTagKey/withResourceNameを必ず優先してください。テキストマッチャーは国際化に弱く、文言の変更で不安定になります。- アニメーションや非同期処理の完了を待つために
Thread.sleepに依存してはいけません。IdlingResourceとIdlingPolicies.setMasterPolicyTimeoutを使用し、testOptions.animationsDisabled = trueを設定してください。 androidx.test.espresso.contrib.BottomNavigationViewActionsをインポートしてはいけません。このクラスはespresso-contrib:3.7.0に含まれていません。代わりにisDescendantOfA(isAssignableFrom(BottomNavigationView::class.java))という回避策を使用してください。PickerActions.setDate(year, month, day)の月パラメーターには、1~12(0~11 ではありません)を必ず渡してください。このアクションはDatePicker.updateDateを呼び出す前に、内部で 1 を引きます。Intents.init()/Intents.release()を必ず呼び出してください(またはIntentsRuleを使用してください)。intended/intendingが機能するのは、これらの呼び出しの間だけです。:espresso-idling-resource:3.7.0はandroidTestImplementationではなく、本番用のimplementationに必ず追加してください。リソースの増減を行うのは本番コードです。IntentsTestRuleは@Deprecatedなので、使用してはいけません。IntentsRule(3.5 以降)または手動のinit/releaseを使用してください。- 推奨:
testOptions.animationsDisabled = trueでアニメーションを全体的に無効にしてください。Settings.Globalフラグを手動で切り替えないでください。 - 推奨: すべての
NoMatchingViewExceptionメッセージを確認してください。メッセージにはビュー階層のダンプが含まれます。目的のビューが存在していても、別のRootにある場合があります。
検証
-
grep -r "Thread.sleep" src/androidTest/を実行すると何も返されないこと(または、待機対象が RenderThread であるスクリーンショットテストのファイルのみが返されること)。 -
grep -r "BottomNavigationViewActions" src/androidTest/を実行すると何も返されないこと。 -
grep -r "IntentsTestRule" src/androidTest/を実行すると何も返されないこと。IntentsRuleまたは手動の init/release を使用してください。 - すべての
inRoot(isDialog())が本番コードのAlertDialogクラスの API に対応していること。そうでない場合、テストは誤った root に対して成功します。 -
./gradlew :<module>:connectedDebugAndroidTestが、上記一覧にある原因によるNoMatchingViewExceptionやPerformExceptionを発生させずに実行できること。 - モジュールの
androidブロックでtestOptions.animationsDisabled = trueが設定されていること。 - 登録されたすべての
IdlingResourceが@AfterでIdlingRegistry.getInstance().unregister(...)を呼び出すこと(または JUnit ルールを使用すること)。
参考資料
- Android Developers — Espresso の概要:https://developer.android.com/training/testing/espresso
- Android Developers — Espresso チートシート(マッチャー、アクション、アサーション):https://developer.android.com/training/testing/espresso/cheat-sheet
- Android Developers — IdlingResource ガイド:https://developer.android.com/training/testing/espresso/idling-resource
- AndroidX Test(Espresso)のリリースノート:https://developer.android.com/jetpack/androidx/releases/test
androidx/test/espresso/matcher/ViewMatchers.java(79~1000 行以上)— マッチャーの全カタログ。識別(withId、withTagKey、withResourceName);テキスト(withText、withSubstring、withHint);状態(isDisplayed、isEnabled、isChecked、withEffectiveVisibility);階層(withParent、hasDescendant、withChild、isRoot、isDescendantOfA)。androidx/test/espresso/matcher/RootMatchers.javaの 51 行目 —DEFAULT = allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable()))。落とし穴です。androidx/test/espresso/action/ViewActions.java—click、longClick、doubleClick、typeText、replaceText、clearText、pressKey、swipeUp/Down/Left/Right、scrollTo、pressImeActionButton、closeSoftKeyboard。androidx/test/espresso/assertion/ViewAssertions.java51~118 行目 —matches、doesNotExist、selectedDescendantsMatch。androidx/test/espresso/contrib/RecyclerViewActions.java200~249 行目 —scrollToPosition、scrollTo、actionOnItemAtPosition、actionOnItem。制約:isAssignableFrom(RecyclerView) AND isDisplayed。androidx/test/espresso/contrib/PickerActions.java36~100 行目 —setDate(year, monthOfYear, dayOfMonth)。monthOfYear は 1~12(49 行目の KDoc)。setTime(hours, minutes)。androidx/test/espresso/IdlingPolicies.java96~112 行目 —setMasterPolicyTimeout、setIdlingResourceTimeout、setMasterPolicyTimeoutWhenDebuggerAttached。androidx/test/espresso/intent/Intents.java—init、release、intended、intending。androidx.test.espresso.intent.rule.IntentsRuleは、最新の JUnit4 ラッパーです。tasks/research/R3-espresso.md— Espresso 3.7.0 の詳細な調査。91~93 行目(BottomNavigationViewActions は存在しません)、311 行目(RootMatchers.DEFAULT)、456~460 行目(PickerActions の月の規則)、811~820 行目(アイドル状態になってから操作)。docs/CORPUS.mdのセクション H.5 — Espresso の基本事項とRootMatchers.DEFAULTの除外項目。