第4章 使用 createComposeRule:测试的起点

说实话,刚接触 Compose UI Test 的人,第一个要面对的困惑就是:怎么让测试能跟 Composable 交互?

答案就是 createComposeRule。它是 Compose 测试的“门把手”,负责启动 Compose 环境,加载你的 UI 组件,并提供一系列断言和操作 API。

我在做第一个 Compose 项目时就踩过这坑——直接用了 ActivityScenarioRule,结果发现很多 Compose 特有的 API 用不了。后来才明白,createComposeRule 才是正主。

核心定位:createComposeRule 是 Compose UI 测试的根规则,它提供了一个轻量级的 Compose 运行时环境,让你不需要启动完整的 Activity 就能测试 Composable。

基本用法:三步走

  1. 在测试类中使用 @get:Rule 声明规则实例。
  2. 在测试方法中通过 rule.setContent { … } 传入你要测试的 Composable。
  3. 使用 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 里,这样每个测试用例都不用重复写,省事。

我的经验:如果需要共享一组 UI 状态(比如登录态、数据加载),可以用 @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 核心流程 @get:Rule 声明 createComposeRule() setContent { Composable } Compose 环境渲染 onNode + assert/perform setContent 内部自动处理 Composition 的启动 和 Root 节点绑定。

这张图概括了 createComposeRule 的完整调用链:从声明规则到渲染 UI,再到最终的断言交互。记住,setContent 是唯一入口,所有后续操作都依赖它。

小技巧:如果测试需要多次启动不同的 UI,可以用 composeTestRule.runOnUiThread { … } 重新调用 setContent。但更推荐的做法是每个测试方法独立编写,这样可读性和隔离性更好。

好了,关于 createComposeRule 的核心用法就这些。下次当你拿到一个新的 Composable 组件时,可以试试用测试先写预期行为,再改代码——这就是 TDD 在 Compose 中的落地方式。我自己在项目里用这个套路发现了不少状态管理 bug,挺管用的。