structuring-a-compose-test について
Jetpack Compose テストの標準的なクラス構成を示し、ルールの選択、状態の配置、テストごとのコンテンツ設定を説明する Skill です。テストの作成や修正を行う開発者向けに、環境の競合やスレッド間の状態問題を避ける方法を扱います。
主な機能
- 01Compose テストクラスの構成
- 02ルールと可変状態を宣言する場所
- 03各テスト内での setContent 呼び出し
- 04アイドル状態での安全な状態読み書き
- 05ルールと runComposeUiTest の混用への注意
活用シーン
- 01新しい Compose テストクラスの作成
- 02テスト中に変更できない状態の修正
- 03セットアップ内の setContent による不安定さの解消
- 04テスト環境が競合するテストの整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill structuring-a-compose-testインストール後、Claude Code で/structuring-a-compose-testを入力して読み込まれたか確認します。
詳細
Compose テストの構成 — androidx の標準クラススケルトン
Compose テストの失敗や不安定化は、アサーションではなく構成の誤りが原因です。setContent { } の内部にある状態はテストスレッドから操作できません。@Before 内で呼び出した setContent は、見えにくい競合状態を引き起こします。@get:Rule と runComposeUiTest { } を組み合わせると、競合する 2 つのテスト環境が作られます。このスキルでは、開発者のテストが同じように動作するよう、androidx.compose.material3 と androidx.compose.foundation で使用されている正確なクラススケルトンを示します。
このスキルを使う場面
- 開発者が新しい
*Test.ktを作成しており、状態、ルール、setContentをどこに配置するか尋ねている場合。 - テストはコンパイルできるものの、テスト中に状態を変更できない場合(「最初のアサーションの後でチェックボックスをどう切り替えればよいですか?」)。
- 開発者が
setContentを@Beforeに貼り付けた結果、テストが不安定になった、または開始できなくなった場合。 - レビュー担当者が、テストで
setContent { }内に状態を宣言していることを指摘した場合。 - 開発者が
@get:Rule createComposeRule()とrunComposeUiTest { }を混在させて、分かりにくい失敗が発生している場合。
このスキルを使わない場面
- どのエントリーポイント(ルールと
runComposeUiTest)を使うかを選ぶ場合は、../../setup/choosing-test-rule-vs-runtest/SKILL.mdを使ってください。 - ビルドで
createComposeRuleを解決できない場合は、../../setup/configuring-test-dependencies/SKILL.mdを使ってください。 - ホスト(Robolectric)とデバイス(インストルメンテーション)のどちらで実行するかを決める場合は、
../../setup/setting-up-host-vs-device-tests/SKILL.mdを使ってください。 - テスト対象が
LazyColumn/LazyRowに特化している場合は、このスケルトンに../testing-lazy-lists/SKILL.mdを追加で適用してください。
前提条件
androidTestImplementationにandroidx.compose.ui:ui-test-junit4、debugImplementationにandroidx.compose.ui:ui-test-manifestが必要です。../../setup/configuring-test-dependencies/SKILL.mdを参照してください。- デフォルトのアクティビティは
androidx.activity.ComponentActivityです(ui-test-manifestによって提供されます)。カスタム Activity を使用する場合は、androidTestソースセットのAndroidManifest.xmlで宣言する必要があります。 - Kotlin のソースセット:
src/androidTest/kotlin/...(インストルメンテーション)またはsrc/test/kotlin/...(Robolectric ホスト)。
ワークフロー
-
1. 本番パッケージを鏡像化します。
MyButtonTest.ktをMyButton.ktと同じパッケージに配置します。クラス名は<ProductionFile>Testです(SwitchTest.kt、LazyListTest.kt)。 -
2. クラスにアノテーションを付けます。 デフォルトの時間枠は
@MediumTestです。意図的に数秒かかるテスト(performTouchInput { longClick() }による長押し、数秒にわたるアニメーション)に限り、@LargeTestを使用します。インストルメンテーションでは@RunWith(AndroidJUnit4::class)が必須です。 -
3. ルールを正確に1つ宣言します。 v2 のエントリポイントを優先してください。デフォルトでコンポジションのディスパッチャーが
StandardTestDispatcherに設定され、kotlinx.coroutines.test.runTestと一致します。
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.test.StandardTestDispatcher
@MediumTest
@RunWith(AndroidJUnit4::class)
class MyButtonTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
}
カスタム Activity(例:FragmentActivity、テスト対象画面の Activity)を使う場合は、reified の v2 ヘルパーを使用します。
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@get:Rule val rule = createAndroidComposeRule<FragmentActivity>(StandardTestDispatcher())
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:60 より引用。
-
4. 状態を
setContentの外でホイストします。 テストで変更または読み取りたい値はすべて、テストメソッドで宣言したプロパティとし、コンポーザブル内で参照によってキャプチャします。 -
5.
setContent { }は@Beforeではなく、@Testメソッド内で呼び出します。 各テストがそれぞれのコンポジションを所有します。@BeforeでsetContentを共有するテストでは、ケースごとにコンテンツを変更できず、フレームワークが想定するテストごとの状態設定も失われます。 -
6. 「テスト → 検索 → アサート → 操作 → 再アサート」の順に進めます。
compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:240-262にある標準的な例(switch_stateChange_movesThumbテスト):
@Test
fun switch_stateChange_movesThumb() {
var checked by mutableStateOf(false) // STATE (hoisted)
rule.setMaterialContent(lightColorScheme()) { // SET CONTENT (in test body)
val spacer = @Composable { Spacer(Modifier.size(16.dp).testTag("spacer")) }
Switch(
modifier = Modifier.testTag(defaultSwitchTag),
checked = checked,
thumbContent = spacer,
onCheckedChange = { checked = it },
)
}
rule.onNodeWithTag("spacer", useUnmergedTree = true) // FIND + ASSERT
.assertLeftPositionInRootIsEqualTo(8.dp)
rule.runOnIdle { checked = true } // ACT (mutate from outside)
rule.onNodeWithTag("spacer", useUnmergedTree = true) // RE-ASSERT
.assertLeftPositionInRootIsEqualTo(28.dp)
rule.runOnIdle { checked = false }
rule.onNodeWithTag("spacer", useUnmergedTree = true).assertLeftPositionInRootIsEqualTo(8.dp)
}
- 7.
rule.runOnIdle { … }を使って外部から状態を読み取ります。 ブロックはwaitForIdle()の後に実行されるため、観測される値は最新のコンポジションを反映しています。
val (index, offset) = rule.runOnIdle {
state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset
}
compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:903-904 より引用。
- 8.
rule.runOnIdle { … }を使って外部から状態を書き込みます。 テストスレッドから直接代入すると、リエコンポーザーとの競合が発生します。
rule.runOnIdle { count = 5 }
パターン
パターン:setContent の内側と外側での状態のホイスト
// WRONG
@Test
fun toggle() {
rule.setContent {
var checked by remember { mutableStateOf(false) } // <-- trapped inside composition
Switch(checked = checked, onCheckedChange = { checked = it })
}
rule.runOnIdle { checked = true } // does not compile / has no reference
}
// WRONG because: the test thread has no reference to `checked`. The state cannot be driven
// from outside, and the test can only verify the very first frame.
// RIGHT
@Test
fun toggle() {
var checked by mutableStateOf(false) // <-- hoisted to test scope
rule.setContent {
Switch(checked = checked, onCheckedChange = { checked = it })
}
rule.onNodeWithTag("switch").assertIsOff()
rule.runOnIdle { checked = true } // mutate via runOnIdle
rule.onNodeWithTag("switch").assertIsOn()
}
パターン:@Before 内での setContent
// WRONG
@Before
fun setUp() {
rule.setContent { MyScreen(state) } // <-- shared across all tests
}
@Test fun stateA() { /* mutate `state` here, but it was already composed without this case */ }
// RIGHT
@Test
fun stateA() {
val state = ScreenState(initial = "A")
rule.setContent { MyScreen(state) } // <-- per-test composition
rule.onNodeWithTag("title").assertTextEquals("A")
}
パターン:クラスごとにルールは1つ — runComposeUiTest と混在させない
// WRONG
class MyTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
@Test fun a() = runComposeUiTest { // <-- second test environment
setContent { /* … */ }
}
}
// WRONG because: createComposeRule and runComposeUiTest each manage an independent test
// environment (composition + MainTestClock + IdlingResourceRegistry). Mixing them produces
// undefined behavior. KDoc warns explicitly: see the ComposeUiTest.android.kt KDoc on
// runComposeUiTest, runAndroidComposeUiTest, runEmptyComposeUiTest, and the v2 variants.
// RIGHT — pick exactly one entry point per class
class MyRuleTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
@Test fun a() { rule.setContent { /* … */ } }
}
// or
class MyFunctionTest {
@Test fun a() = runComposeUiTest { setContent { /* … */ } }
}
パターン:カスタム Activity には独自のマニフェストエントリが必要
// RIGHT — ComponentActivity comes free from ui-test-manifest
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
// RIGHT — a custom Activity must be declared in src/androidTest/AndroidManifest.xml
@get:Rule val rule = createAndroidComposeRule<MyActivity>(StandardTestDispatcher())
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".MyActivity" />
</application>
</manifest>
パターン:MediumTest と LargeTest
@MediumTest // default — sub-second tests
class SwitchTest { /* … */ }
@LargeTest // long-clicks, multi-second animations
class CombinedClickableTest { /* … */ }
@MediumTest は SwitchTest.kt:76 や、material3/foundation の大半のテストスイートで使用されています。@LargeTest は、合計実行時間が意図的に medium の時間枠を超えるテストに限って使用します。
必須ルール
- クラスには
@RunWith(AndroidJUnit4::class)とサイズアノテーション(デフォルトでは@MediumTest)を必ず付けてください。 - クラスごとにルールを必ず1つだけ宣言してください。
createComposeRule(...)、createAndroidComposeRule<A>(...)、createEmptyComposeRule()のいずれかを使用します。同じクラス内で@get:RuleのルールとrunComposeUiTest { }を組み合わせてはいけません。KDoc の参照先:compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:158, 204, 247, 338。 - v2 のエントリポイントである
androidx.compose.ui.test.junit4.v2.createComposeRule/createAndroidComposeRuleを必ず優先してください。v1 の形式はStandardTestDispatcherではなくUnconfinedTestDispatcherを使用するため、@Deprecated(level = WARNING)になっています(skydoves 指示 #6)。 - テストで状態を操作または監視する必要がある場合、可変状態は
setContent { }より前にvar x by mutableStateOf(...)として必ず宣言してください。 rule.setContent { }は@Testメソッド内で必ず呼び出してください。@Before内で呼び出してはいけません。- 外部から状態を変更するには、
rule.runOnIdle { state = … }を必ず使用してください(先にアイドル状態にするのが望ましくない場合、たとえばmainClock.autoAdvance = falseの場合はrule.runOnUiThread { … }を使用します)。skydoves 指示 #5 を参照してください。詳細は../../synchronization/synchronizing-with-idle/SKILL.mdを参照してください。 - 外部から状態を読み取るには、
rule.runOnIdle { state.value }を必ず使用してください。テストスレッドから直接読み取ると、誤ったフレームのスナップショットを取得します。 - 推奨: 本番コードで
constとして宣言したModifier.testTag(...)で検索してください(skydoves 指示 #1)。テキスト検索は i18n の変更に弱いです。../../finders/finding-nodes-by-tag-text-content/SKILL.mdを参照してください。 - 論理的な状態変更ごとに、Test → Find → Assert → Act → Re-assert の順に行ってください。複数ステップのテストでは、Act → Re-assert のペアを追加で重ねます(
SwitchTest.kt:140-147)。
検証
- クラスに
@RunWith(AndroidJUnit4::class)と@MediumTest(または@LargeTest)が付いている。 -
@get:Ruleがちょうど1つ宣言され、それがcreateComposeRule(...)/createAndroidComposeRule(...)/createEmptyComposeRule()のインスタンスである。 - 同じクラス内に
runComposeUiTest { }の呼び出しがない。 -
setContent { }は@Testメソッド内にのみあり、@Before/@BeforeEach内にはない。 - テストで変更するすべての状態が
setContentより前にvar x by mutableStateOf(...)として宣言され、コンポーザブル内で参照によってキャプチャされている。 -
setContent後の状態変更はすべてrule.runOnIdle { }またはrule.runOnUiThread { }を経由している。 - フレームをまたぐ状態の読み取りはすべて
rule.runOnIdle { }内で行われている。 - テストメソッド内に
Thread.sleepがない(skydoves 指示 #7)。../../synchronization/synchronizing-with-idle/SKILL.mdを参照してください。 - カスタム Activity が
src/androidTest/AndroidManifest.xmlに宣言されている(本番用のAndroidManifest.xmlだけに宣言するのではなく)。
参考資料
- Compose のテスト概要:https://developer.android.com/develop/ui/compose/testing
- Compose のテスト設定:https://developer.android.com/develop/ui/compose/testing#setup
- Compose テストのチートシート:https://developer.android.com/develop/ui/compose/testing-cheatsheet
- 標準的なクラスのひな形:
compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:76-263 - カスタム Activity のルール:
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:57-60 - v2 ルールファクトリー:
compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/v2/AndroidComposeTestRule.android.kt - 「ルールと runComposeUiTest を混在させない」の KDoc:
compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:158, 204, 247, 338 runOnIdleのセマンティクス:compose/ui/ui-test-junit4/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvmAndAndroid.kt