关于 structuring-a-compose-test
这项 Skill 介绍 Jetpack Compose 测试的规范类结构,涵盖规则选择、状态放置和每个测试单独设置内容。面向创建或修复测试的开发者,帮助避免测试环境冲突和跨线程状态问题。
主要功能
- 01说明 Compose 测试类结构
- 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 { } 结合使用会创建两个相互竞争的测试环境。本技能封装了 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