mcpField
S
structuring-a-compose-test

structuring-a-compose-test

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

structuring-a-compose-test について

Jetpack Compose テストの標準的なクラス構成を示し、ルールの選択、状態の配置、テストごとのコンテンツ設定を説明する Skill です。テストの作成や修正を行う開発者向けに、環境の競合やスレッド間の状態問題を避ける方法を扱います。

主な機能

  1. 01Compose テストクラスの構成
  2. 02ルールと可変状態を宣言する場所
  3. 03各テスト内での setContent 呼び出し
  4. 04アイドル状態での安全な状態読み書き
  5. 05ルールと runComposeUiTest の混用への注意

活用シーン

  1. 01新しい Compose テストクラスの作成
  2. 02テスト中に変更できない状態の修正
  3. 03セットアップ内の setContent による不安定さの解消
  4. 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

コメント