setting-up-host-vs-device-tests について
Android Compose 開発者向けに、Robolectric のホストテストとデバイステストの選び方を示すスキルです。テストの配置先や、時計の制御、スクリーンショット時の `Thread.sleep` の例外を説明します。
主な機能
- 01機能に応じたホスト・デバイステストの選択
- 02ソースセットとクラス構成の案内
- 03テストクロックによるアニメーション・ジェスチャー制御
- 04スクリーンショット・リップル・アクセシビリティの制約
- 05`Thread.sleep` が許される場面の区別
活用シーン
- 01Compose UI テストを `test/` と `androidTest/` のどちらに置くか判断
- 02ホストとエミュレーターでの結果の違いを調査
- 03スクリーンショット、リップル、アクセシビリティの検証先を決定
- 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.ktKMP ソースセットを使用しない一般的なアプリモジュールでは、同等の場所は
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が必要です。