第4章 使用 createComposeRule:测试的起点
说实话,刚接触 Compose UI Test 的人,第一个要面对的困惑就是:怎么让测试能跟 Composable 交互?
答案就是 createComposeRule。它是 Compose 测试的“门把手”,负责启动 Compose 环境,加载你的 UI 组件,并提供一系列断言和操作 API。
我在做第一个 Compose 项目时就踩过这坑——直接用了 ActivityScenarioRule,结果发现很多 Compose 特有的 API 用不了。后来才明白,createComposeRule 才是正主。
基本用法:三步走
- 在测试类中使用
@get:Rule声明规则实例。 - 在测试方法中通过
rule.setContent { … }传入你要测试的 Composable。 - 使用
rule.onNodeWithText()等查找器进行交互和断言。
@RunWith(AndroidJUnit4::class)
class GreetingTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun greeting_showsHelloName() {
// 启动 Composable
composeTestRule.setContent {
Greeting(name = "Jetpack")
}
// 验证文本存在
composeTestRule.onNodeWithText("Hello, Jetpack!")
.assertExists()
}
}
你看,就这么简单。但这里有个细节——setContent 会立刻把 Composable 挂载到测试环境中。我习惯把它放在 @Before 里,这样每个测试用例都不用重复写,省事。
@Before 统一 setContent。但如果每个用例要传不同参数,那就老老实实放在每个 @Test 方法里。
setContent 的几种玩法
你可能以为 setContent 只能传一个 Composable 函数。其实它可以传任意表达式,包括带状态的、带 ViewModel 的、甚至嵌套多个组件。
- 无状态组件:直接传 lambda,如
setContent { Text("Hello") } - 带状态组件:可以先在测试中创建
mutableStateOf,再传进去 - 带 ViewModel:可以用
viewModel()或手动实例化后传入
@Test
fun counter_canIncrement() {
composeTestRule.setContent {
val count = remember { mutableIntStateOf(0) }
Counter(
count = count.intValue,
onIncrement = { count.intValue++ }
)
}
composeTestRule.onNodeWithText("0").assertExists()
composeTestRule.onNodeWithText("+").performClick()
composeTestRule.onNodeWithText("1").assertExists()
}
这里我特别想说:状态管理一定要在 setContent 内部用 remember。我曾经把状态定义在外面,结果每次重组都重置了,坑了我半天。
createComposeRule 与其它规则的对比
| 规则名称 | 适用场景 | 启动速度 | 能测试系统交互? |
|---|---|---|---|
| createComposeRule | 纯 Compose 组件,不需要 Activity | 快 | 否 |
| createAndroidComposeRule | 需要 Activity 但不用生命周期埋点 | 中等 | 有限 |
| ActivityScenarioRule | 传统 Activity + Compose 混合测试 | 慢 | 完全支持 |
嗯,如果你测试的是独立组件(比如自定义 Button、Text),直接用 createComposeRule 就够了。如果你需要访问 Activity 的 intent 或 result,那就得用另外两个了。
避坑指南:setContent 的调用时机
- 不要多次调用 setContent:第二次调用会替换第一次的内容。我见过有人想在同一个测试里切换不同 UI,结果第二次调用后之前的断言全失败了。
- 不要忘了
waitForIdle:虽然大多数 API 会自动等待,但如果你手动触发了动画或异步任务,最好显式调用composeTestRule.waitForIdle()。 - 注意
@Before中的作用域:如果你在@Before中 setContent,后续所有测试方法都共享同一个 Composable 实例。要小心状态污染。
setContent 放到了 @Before 里,然后有个测试用例调用了 performClick 改变了状态,导致后面的用例全部失败。解决方案是:要么在每个测试里重新 setContent,要么用 composeTestRule.runOnUiThread 重置状态。
完整示例:表单验证测试
@RunWith(AndroidJUnit4::class)
class LoginFormTest {
@get:Rule
val rule = createComposeRule()
@Test
fun showsError_whenEmailEmpty() {
rule.setContent {
LoginForm(onLogin = { _, _ -> })
}
rule.onNodeWithText("登录").performClick()
rule.onNodeWithText("邮箱不能为空").assertExists()
}
}
这个例子很简单,但实际工作中我经常用它来验证输入校验、按钮禁用等逻辑。setContent 就像一个沙盒,想放什么 UI 就放什么。
知识结构一览
这张图概括了 createComposeRule 的完整调用链:从声明规则到渲染 UI,再到最终的断言交互。记住,setContent 是唯一入口,所有后续操作都依赖它。
composeTestRule.runOnUiThread { … } 重新调用 setContent。但更推荐的做法是每个测试方法独立编写,这样可读性和隔离性更好。
好了,关于 createComposeRule 的核心用法就这些。下次当你拿到一个新的 Composable 组件时,可以试试用测试先写预期行为,再改代码——这就是 TDD 在 Compose 中的落地方式。我自己在项目里用这个套路发现了不少状态管理 bug,挺管用的。