mcpField
S
structuring-a-compose-test

structuring-a-compose-test

github.com/skydoves测试与质量33969 次安装22 周前更新
代码仓库

关于 structuring-a-compose-test

这项 Skill 介绍 Jetpack Compose 测试的规范类结构,涵盖规则选择、状态放置和每个测试单独设置内容。面向创建或修复测试的开发者,帮助避免测试环境冲突和跨线程状态问题。

主要功能

  1. 01说明 Compose 测试类结构
  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 { } 结合使用会创建两个相互竞争的测试环境。本技能封装了 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。
  • 默认 Activity 为 androidx.activity.ComponentActivity(由 ui-test-manifest 提供)。对于自定义 Activity,androidTest 源集 MUST 在 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. 严格声明一个规则。 优先使用 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. 在 @Test 方法内部调用 setContent { },而不是在 @Before 中调用。 每个测试都拥有自己的组合。在 @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")
}
模式:每个类使用一条规则——绝不与 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 { /* … */ }

SwitchTest.kt:76 和大多数 material3/foundation 测试套件都使用 @MediumTest。@LargeTest 仅用于总耗时有意超过中等预算的测试。

强制规则

  • 必须使用 @RunWith(AndroidJUnit4::class) 注解类,并添加尺寸注解(默认使用 @MediumTest)。
  • 必须为每个类声明且仅声明一个规则——使用 createComposeRule(...)、createAndroidComposeRule<A>(...) 或 createEmptyComposeRule()。不得在同一个类中同时使用 @get:Rule 规则和 runComposeUiTest { }。参见 compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:158, 204, 247, 338 中引用的 KDoc。
  • 必须优先使用 v2 入口 androidx.compose.ui.test.junit4.v2.createComposeRule / createAndroidComposeRule。v1 形式已标记为 @Deprecated(level = WARNING),因为它们使用的是 UnconfinedTestDispatcher,而非 StandardTestDispatcher(skydoves 指令 #6)。
  • 必须在 setContent { } 之前将可变状态提升为 var x by mutableStateOf(...),前提是测试需要驱动或观察该状态。
  • 必须在 @Test 方法内部调用 rule.setContent { }。不得在 @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)。文本查找器容易受国际化影响。参见 ../../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,且它是 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

评论

支持 Markdown