mcpField
W
writing-espresso-tests

writing-espresso-tests

github.com/skydovesテスト・品質33963 回インストール22 週間前に更新
リポジトリ

writing-espresso-tests について

Android Views の Activity、Fragment、Dialog を対象とする Espresso のインストルメンテーションテスト向けスキルガイドです。ビューの検索、操作、アサーション、同期時の注意点を扱い、Compose など別のテスト手法との使い分けも示します。

主な機能

  1. 01ビュー検索、操作、アサーション
  2. 02ダイアログとポップアップの Root 選択
  3. 03RecyclerView と AdapterView の操作
  4. 04Intent のテストとスタブ化
  5. 05非同期処理用 IdlingResource の登録

活用シーン

  1. 01Android Views の操作テスト
  2. 02ビューの未検出や複数一致の調査
  3. 03ダイアログボタンとポップアップメニューのテスト
  4. 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.md H.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(...) 呼び出しでは、内部的に次の処理が行われます。
    1. dagger で注入された mainThreadExecutor を介して、メインスレッドでアクションを送信します。
    2. 次の条件が満たされるまで uiController.loopMainThreadUntilIdle() を呼び出します。メインの MessageQueue が空であること、IdlingRegistry に登録されたすべての IdlingResource が isIdleNow == true を返すこと、登録されたメイン以外のすべての Looper がアイドル状態であること、そして AsyncTask スレッドプールがアイドル状態であること。
    3. ビューを解決します(現在の Root の階層に対して viewMatcher を適用します)。0 件の場合は NoMatchingViewException、2 件以上の場合は AmbiguousViewMatcherException が発生します。
    4. viewAction.getConstraints().matches(view) を確認し、false の場合は PerformException をスローします。
    5. viewAction.perform(uiController, view)(または viewAssertion.check(view, null))を呼び出します。
    6. 戻る前に、メインスレッドが再びアイドル状態になるまでループします。

これは アイドル状態にしてからアクションを実行する モデルです。ローカルでは動作するのに 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.java 51~118 行目 — matches、doesNotExist、selectedDescendantsMatch。
  • androidx/test/espresso/contrib/RecyclerViewActions.java 200~249 行目 — scrollToPosition、scrollTo、actionOnItemAtPosition、actionOnItem。制約:isAssignableFrom(RecyclerView) AND isDisplayed。
  • androidx/test/espresso/contrib/PickerActions.java 36~100 行目 — setDate(year, monthOfYear, dayOfMonth)。monthOfYear は 1~12(49 行目の KDoc)。setTime(hours, minutes)。
  • androidx/test/espresso/IdlingPolicies.java 96~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 の除外項目。

コメント