mcpField
S
setting-up-host-vs-device-tests

setting-up-host-vs-device-tests

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

setting-up-host-vs-device-tests について

Android Compose 開発者向けに、Robolectric のホストテストとデバイステストの選び方を示すスキルです。テストの配置先や、時計の制御、スクリーンショット時の `Thread.sleep` の例外を説明します。

主な機能

  1. 01機能に応じたホスト・デバイステストの選択
  2. 02ソースセットとクラス構成の案内
  3. 03テストクロックによるアニメーション・ジェスチャー制御
  4. 04スクリーンショット・リップル・アクセシビリティの制約
  5. 05`Thread.sleep` が許される場面の区別

活用シーン

  1. 01Compose UI テストを `test/` と `androidTest/` のどちらに置くか判断
  2. 02ホストとエミュレーターでの結果の違いを調査
  3. 03スクリーンショット、リップル、アクセシビリティの検証先を決定
  4. 04不安定なアニメーション待ちを置き換え

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill setting-up-host-vs-device-tests

インストール後、Claude Code で/setting-up-host-vs-device-testsを入力して読み込まれたか確認します。

詳細

ホストテストとデバイステストの設定 — 適切なソースセットを選ぶ

Compose テストは変更なしで、Robolectric(JVM、高速、エミュレーター不要)または実機/仮想デバイス(完全な Android スタック、RenderThread、アクセシビリティ)のどちらでも実行できます。同じ runComposeUiTest { setContent { … } } ブロックが両方でコンパイルされ、異なるのは基盤となる Looper と Choreographer だけです。このスキルでは、各テストをどのフレーバーに配置すべきか、Robolectric のクラスのひな形、そして正当な例外である、スクリーンショットテストで RenderThread を待つための Thread.sleep の使用について説明します。

このスキルを使う場合

  • ユーザーが Compose UI テストを始めていて、「test/ と androidTest/ のどちらですか?」と尋ねている場合。
  • テストがローカルのエミュレーターでは成功するものの、CI の Robolectric ランナーでは失敗する(またはその逆の)場合。
  • ユーザーから enableAccessibilityChecks(...) による Build.FINGERPRINT == "robolectric" の警告について報告があった場合。
  • スクリーンショットテストで、ホストランナー上に黒い、または空の PNG が生成される場合。
  • ripple/pressInteraction テストで Robolectric 上にリップルが描画されず、ユーザーが原因を調べている場合。
  • ユーザーが「アニメーションを待つ」ために Thread.sleep(1000) を書き、動作が不安定になる理由を尋ねている場合。
  • ユーザーが androidDeviceTest/androidHostTest ソースセット(androidx 自体で使用)について言及している場合。

このスキルを使わない場合

  • 依存関係がまだ正しく設定されていない場合は、まず ./configuring-test-dependencies/SKILL.md を参照してください。
  • createComposeRule() と runComposeUiTest { } のどちらを使うかの選択については、./choosing-test-rule-vs-runtest/SKILL.md を参照してください。
  • テストが適切なフレーバーで実行されているものの、アイドル状態やアニメーションで不安定になる場合は、../../synchronization/synchronizing-with-idle/SKILL.md と ../../synchronization/testing-animations-deterministically/SKILL.md を参照してください。

前提条件

  • 選択したフレーバーの適切な設定に androidx.compose.ui:ui-test、ui-test-junit4、ui-test-manifest が含まれていること。./configuring-test-dependencies/SKILL.md を参照してください。
  • ホストテストの場合:testImplementation に org.robolectric:robolectric があり、Android ブロックに testOptions { unitTests.isIncludeAndroidResources = true } があること。
  • デバイステストの場合:設定済みのエミュレーターまたは物理デバイスがあり、testInstrumentationRunner に androidx.test.runner.AndroidJUnitRunner(または Hilt のサブクラス)が指定されていること。
  • MainTestClock のセマンティクスを理解していること。同期関連のスキルセットを参照してください。

ワークフロー

  • 1. 速度ではなく、機能でフレーバーを選択します。 次の表を参照してください。

    機能ホスト(Robolectric、src/test/)デバイス(インストルメンテーション、src/androidTest/)
    再コンポーズと状態変更動作します動作します
    レイアウトと測定動作します動作します
    performTouchInput によるタッチ入力動作します(合成イベント)動作します(実際の入力パイプライン)
    Modifier.indication のリップル動作しません(RenderThread が駆動されないため)動作します
    スクリーンショットのキャプチャ(captureToImage)動作しません(実際のサーフェスがないため)動作します
    enableAccessibilityChecks(...)結論は出せません — ComposeUiTestExt.android.kt:50-53 が Log.w("ComposeUiTest", "Accessibility checks are currently not supported by Robolectric") を記録し、バリデーターもインストールしますが、Robolectric はアクセシビリティサービスを忠実に駆動しません(b/332778271)。テストが成功しても、結論は出せないものとして扱ってください。動作します(@RequiresApi(34))
    実時間のクロック変更が必要なジェスチャー検出器(ダブルタップ、長押しのタイミング)手動で mainClock.advanceTimeBy(...) を実行した場合のみ動作します自動的に動作します
    アクティビティのライフサイクル(実際の onPause/onResume)近似動作です実際の動作です
    最小 API レベル23(compose/ui/ui-test/src/androidHostTest/.../Constants.kt の internal const val RobolectricMinSdk = 23)モジュールの minSdk
    速度(おおよその目安)数秒数十秒+エミュレーターの起動
    CI の必要環境JVM のみエミュレーターサービスまたは Firebase Test Lab

    ロジック、再コンポーズ、ファインダーのテストにはホストを選択してください。RenderThread に関わるもの、Modifier.indication を伴う実際のアニメーション、スクリーンショットのゴールデンテスト、アクセシビリティ検証にはデバイスを選択してください。

  • 2. 対応するソースセットにファイルを配置します。 androidx の規約は次のとおりです。

    src/
    ├── androidHostTest/        # = test/ — Robolectric on JVM
    │   └── kotlin/.../FooTest.kt
    ├── androidDeviceTest/      # = androidTest/ — instrumentation
    │   └── kotlin/.../FooScreenshotTest.kt
    └── androidCommonTest/      # helpers shared by both
        └── kotlin/.../FooTestHelpers.kt
    

    KMP ソースセットを使用しない一般的なアプリモジュールでは、同等の場所は src/test/(ホスト)と src/androidTest/(デバイス)です。runComposeUiTest { setContent { … } } の本体は、どちらでも変更せずにコンパイルできます。実行時に異なるのは Looper/Choreographer だけです。

  • 3. ホストテストクラスの基本構成を設定します。 compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt から、androidx の標準パターンをコピーします。

@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
@OptIn(ExperimentalTestApi::class)
class MyHostTest {
    @Before
    fun setup() {
        // capture the master timeout so we can restore it afterwards
        masterTimeout = IdlingPolicies.getMasterIdlingPolicy()
    }

    @After
    fun tearDown() {
        masterTimeout?.let {
            IdlingPolicies.setMasterPolicyTimeout(it.idleTimeout, it.idleTimeoutUnit)
        }
    }

    @Test
    fun stateChange() = runComposeUiTest {
        setContent { ClickCounter() }
        onNodeWithText("Click me").performClick()
        onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
    }

    private var masterTimeout: IdlingPolicy? = null
}

AndroidJUnit4::class は、JVM では Robolectric に、デバイスでは AndroidJUnit4ClassRunner に処理を委譲するため、同じクラスをどちらでも使用できます。@RunWith(RobolectricTestRunner::class) も使用できますが、クラスがホスト専用になります。

  • 4. デバイステストクラスの基本構成を設定します。
@RunWith(AndroidJUnit4::class)
class MyDeviceTest {
    @get:Rule val rule = createComposeRule()       // or v2 import — see ./choosing-test-rule-vs-runtest/

    @Test
    fun stateChange() {
        rule.setContent { ClickCounter() }
        rule.onNodeWithText("Click me").performClick()
        rule.onNodeWithText("Click count", substring = true).assertTextEquals("Click count: 1")
    }
}
  • 5. アニメーションやジェスチャー検出のテストでは、mainClock.advanceTimeBy(...) を使用します。 Robolectric では、ジェスチャー検出器(ダブルタップ、長押し)はテストクロックの進行に依存します。実際の経過時間の刻みは受け取りません。RobolectricComposeTest.kt のソースにも、ジェスチャー検出器はクロックの変化を通じてイベントを検出するため、mainClock.advanceTimeBy(...) を手動で呼び出す必要があると明記されています。再コンポーザーのフレームクロックによって駆動される animateFloatAsState にも同じことが当てはまります。詳しくは ../../synchronization/testing-animations-deterministically/SKILL.md を参照してください。

  • 6. Thread.sleep が正当に使える唯一のケース、つまり RenderThread を待つスクリーンショットテストを把握します。 Compose の IdlingResource は、再コンポーザー、スナップショット、フレームクロックの待機処理を集約しますが、RenderThread はその集約の対象外です。リップルアニメーション(Modifier.indication)や、プラットフォームのレンダリングパイプラインが所有する描画時アニメーションは、mainClock.advanceTimeBy や waitForIdle では待機できません。compose/material3/material3/src/androidDeviceTest/.../ToggleButtonScreenshotTest.kt:115-123 より:

rule.mainClock.autoAdvance = false
rule.onNode(isToggleable()).performTouchInput { down(center) }

rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()                          // wait for measure
rule.mainClock.advanceTimeBy(milliseconds = 200)

// Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't wait for
// synchronization. Instead just wait until after the ripples are finished animating.
Thread.sleep(300)

assertAgainstGolden("toggleButton_lightTheme_defaultToPressed")

これが唯一のケースです。それ以外での Thread.sleep はすべて避けるべきです。MainTestClock との同期がずれ、開発者を疲弊させる不安定なテストが発生します。アニメーションには mainClock.advanceTimeBy(durationMs)(テストクロック)を、外部状態には rule.waitUntil(timeoutMillis = …) { … }(実時間)を使用してください。

  • 7. ホストテストでは Thread.sleep を禁止します。 ホストテストではそもそも RenderThread を駆動できないため、スクリーンショットの例外は適用されません。ホストテスト内の Thread.sleep(N) はすべて、mainClock.advanceTimeBy(N)(アニメーションの場合)または rule.waitUntil { … }(外部状態の場合)に置き換えてください。

  • 8. スクリーンショット、リップル、アクセシビリティのテストはデバイスのソースセットに配置します。 ホストランナーではこれらを実行できません。具体的には次のとおりです。

    • スクリーンショット:captureToImage() には実際の Surface が必要です。ホストではピクセルデータが返されません。
    • リップル:Modifier.indication は RenderThread で描画されるため、ホストでは状態を観測できません。
    • アクセシビリティ:enableAccessibilityChecks(...) は @RequiresApi(34) です。Robolectric では、両方の拡張実装が Build.FINGERPRINT.lowercase() == "robolectric" をチェックし、Log.w の警告を出力し、さらにバリデーターをインストールします。しかし、Robolectric はアクセシビリティサービスを忠実に動作させないため、テストが成功しても結論は出せません(b/332778271)。信頼できる結果を得るには、実機でアクセシビリティチェックを実行してください。
  • 9. 高速なロジック/再コンポジション/状態テストはホストのソースセットに配置します。 Robolectric で快適に実行できる例:

    • StateRestorationTester.emulateSavedInstanceStateRestore() のフロー。
    • 状態変化のみの検証(assertTextEquals、assertIsOn)。
    • 速度に応じた物理演算に依存しない LazyList のファインダー/スクロールテスト。
    • ラスタライズを必要としない、mainClock.advanceTimeBy(durationMs) で駆動するアニメーションのスナップショット。

パターン

パターン:間違いと正しい例 — ホストでのスクリーンショットテスト
// WRONG — placed in src/test/ (Robolectric)
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
class ButtonScreenshotTest {
    @Test
    fun pressed() = runComposeUiTest {
        setContent { Button(onClick = {}) { Text("OK") } }
        onNode(hasText("OK")).performTouchInput { down(center) }
        onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
    }
}
// WRONG because: Robolectric has no real Surface, so captureToImage() returns black/empty
// pixels and the ripple from Modifier.indication never draws (RenderThread is not driven).
// The test will produce a false-positive pass or a meaningless golden file.
// RIGHT — placed in src/androidTest/ (device)
@RunWith(AndroidJUnit4::class)
class ButtonScreenshotTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun pressed() {
        rule.setContent { Button(onClick = {}) { Text("OK") } }
        rule.mainClock.autoAdvance = false
        rule.onNode(hasText("OK")).performTouchInput { down(center) }
        rule.mainClock.advanceTimeByFrame()
        rule.waitForIdle()
        rule.mainClock.advanceTimeBy(milliseconds = 200)
        Thread.sleep(300)                          // legitimate — RenderThread for ripples
        rule.onNode(hasText("OK")).captureToImage().assertAgainstGolden("pressed")
    }
}
パターン:誤りと正しい例 — ホストアニメーションテストでの Thread.sleep
// WRONG — host test
@Test
fun fadeIn() = runComposeUiTest {
    val target = mutableStateOf(0f)
    setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value)) }
    target.value = 1f
    Thread.sleep(500)                                // smell
    onNode(isRoot()).captureToImage()                // also broken on host
}
// WRONG because: Thread.sleep desyncs from MainTestClock. The test clock has not advanced,
// the recomposer has not run withFrameNanos, and the animation made zero observable progress.
// RIGHT — host test (recomposition only — no pixel capture)
@Test
fun fadeIn() = runComposeUiTest {
    mainClock.autoAdvance = false
    val target = mutableStateOf(0f)
    setContent { Box(Modifier.alpha(animateFloatAsState(target.value).value).testTag("box")) }
    runOnUiThread { target.value = 1f }
    mainClock.advanceTimeByFrame()                   // kick off
    mainClock.advanceTimeBy(durationMillis = 500)    // step forward by test clock
    onNodeWithTag("box").assertIsDisplayed()
}

フェードをピクセル単位で検証するには、テストをデバイスのソースセットに移し、captureToImage() を使用してください。

パターン:誤りと正しい例 — ホストでのアクセシビリティチェック
// WRONG — host
@Test
fun submitIsLabelled() = runComposeUiTest {
    enableAccessibilityChecks()                      // logs warning, runs no checks
    setContent { Submit() }
    onNodeWithTag("submit").performClick()
}
// WRONG because: both ui-test-accessibility and ui-test-junit4-accessibility detect
// Build.FINGERPRINT.lowercase() == "robolectric", emit a Log.w, AND still install the
// validator — but Robolectric does not faithfully drive accessibility services, so the
// result is inconclusive (b/332778271). Run accessibility checks on a real device.
// RIGHT — device
@RunWith(AndroidJUnit4::class)
class SubmitA11yTest {
    @get:Rule val rule = createAndroidComposeRule<MainActivity>()

    @Test fun submitIsLabelled() {
        rule.enableAccessibilityChecks()
        rule.setContent { Submit() }
        rule.onNodeWithTag("submit").performClick()  // accessibility checks run automatically
    }
}
パターン:共通の本体、2 つのソースセット

テストで共通 API だけを使用する場合、同じ runComposeUiTest { ... } の本体を両方のソースセットでコンパイルできます。本体を androidCommonTest/ に配置し、薄いラッパーを 2 つ作成します。1 つは androidHostTest/ に配置し(@RunWith(AndroidJUnit4::class) @Config(minSdk = 23) を指定)、もう 1 つは androidDeviceTest/ に配置します(@Config は不要)。これが、androidx 自身の ui-test モジュールがコードを重複させずに両方の種類をテストする方法です。KMP ソースセットのないアプリモジュールでは、適切な種類のソースセットに本体をインラインで記述することを推奨します。

必須ルール

  • 必ず守ること: スクリーンショット/captureToImage/リップル/Modifier.indication に依存するテストをホストのソースセットに配置してはいけません。RenderThread が駆動されないため、テストは成功したように見えても意味がありません。
  • 必ず守ること: ホストテストでの enableAccessibilityChecks(...) の結果に依存してはいけません。どちらの拡張機能も Build.FINGERPRINT.lowercase() == "robolectric" の場合は警告をログに記録し、バリデーターはインストールしますが、Robolectric はアクセシビリティサービスを忠実に駆動しないため、結果は決定的ではありません。アクセシビリティチェックは実機の API 34 以降で実行してください。
  • 必ず守ること: いかなる場合も、ホストテストで Thread.sleep を使用してはいけません。スクリーンショットに関する例外は適用されません(ホストには RenderThread がありません)。
  • 必ず守ること: リップル/スクリーンショットのゴールデンキャプチャのために RenderThread を待つ場合を除き、デバイステストで Thread.sleep を使用してはいけません。Skydoves の率直な見解 #7:Thread.sleep は要注意の兆候です。それ以外の場合は、mainClock.advanceTimeBy(durationMs)(テストクロック)または rule.waitUntil(timeoutMillis) { ... }(実時間のクロック)に置き換えてください。詳しくは ../../synchronization/synchronizing-with-idle/SKILL.md を参照してください。
  • 必ず守ること: Robolectric のホストテストには、@Config(minSdk = 23) 以上を指定してください。これより低い SDK レベルは androidx のホストテスト基盤でサポートされていません(internal const val RobolectricMinSdk = 23)。
  • 必ず守ること: ジェスチャー検出またはアニメーションのホストテストでは、mainClock.advanceTimeBy(...) を使ってクロックを手動で進めるよう指定してください。Robolectric は実時間のシグナルからテストクロックを進めません。
  • 必ず守ること: Robolectric がホストの Compose テスト中にマージ済みリソースを読み取れるよう、モジュールの android { } ブロックに testOptions { unitTests.isIncludeAndroidResources = true } を設定してください。
  • 推奨: まずホストテストを作成して、素早くフィードバックを得てください。RenderThread、アクセシビリティ、実際の入力タイミングが必要な場合に限り、デバイステストに移行してください。
  • 推奨: テストを両方の種類で実行する必要がある場合は、本体を androidCommonTest/(KMP)に切り出すか、両方の種類から呼び出すヘルパー関数に切り出してください。

検証

  • ./gradlew :<module>:testDebugUnitTest を実行し、すべてのホストテストが成功すること。./gradlew :<module>:connectedDebugAndroidTest を実行し、すべてのデバイステストが成功すること。
  • どのホストテストにも Thread.sleep が含まれていないこと(grep -r "Thread.sleep" src/test src/androidHostTest)。
  • ホストのソースセットに captureToImage、enableAccessibilityChecks、および Modifier.indication のリップルに依存するテストが含まれていないこと。
  • デバイスのソースセットにあるすべての Thread.sleep に、RenderThread/スクリーンショットに関する根拠を説明するコメントがあること。
  • Robolectric のホストテストクラスに @RunWith(AndroidJUnit4::class)(または @RunWith(RobolectricTestRunner::class))と @Config(minSdk = 23)(またはそれ以上)の両方が指定されていること。
  • アニメーション/ジェスチャーのホストテストで mainClock.autoAdvance = false を設定し、mainClock.advanceTimeBy(durationMs) を明示的に呼び出していること。

参考資料

  • Compose のテスト概要(Android Developers):https://developer.android.com/develop/ui/compose/testing
  • Robolectric — Compose のサポート:http://robolectric.org/
  • Compose UI のリリースノート:https://developer.android.com/jetpack/androidx/releases/compose-ui
  • アニメーションのテスト(Android Developers):https://developer.android.com/develop/ui/compose/animation/testing
  • compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/RobolectricComposeTest.kt — ホストクラスの標準的なスケルトン、@RunWith(AndroidJUnit4::class) @Config(minSdk = RobolectricMinSdk)、ジェスチャー検出器のクロックに関するコメント、IdlingPolicies のセットアップとティアダウン。
  • compose/ui/ui-test/src/androidHostTest/kotlin/androidx/compose/ui/test/Constants.kt — internal const val RobolectricMinSdk = 23。
  • compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ToggleButtonScreenshotTest.kt:115-123 — assertAgainstGolden の前に、リップルの完了を RenderThread 上で待つ正当な標準例である Thread.sleep(300)。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/RobolectricIdlingStrategy.android.kt — ホストのアイドルループを駆動する Robolectric のアイドリング戦略。
  • compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt — recomposer、スナップショット、フレームクロックのアグリゲーター(1 回の呼び出しにつき最大 100 フレーム)。RenderThread は含まれないため、リップルのテストには Thread.sleep が必要です。

コメント