3、ComposeTestRule入门:createComposeRule、setContent、基本断言

好,我们正式开始写测试了。

前面两章我们聊了为什么要做测试,以及测试的分类。现在,是时候把手弄脏了——写第一行 Compose UI 测试代码。

这一章的核心就三个东西:createComposeRulesetContent基本断言。说白了,就是「怎么启动一个 Composable」、「怎么往里面塞 UI」、「怎么检查它对不对」。我当年刚接触 Compose 测试时,觉得这玩意儿跟 Espresso 差别挺大,但摸清楚套路后,发现其实比传统 View 测试要爽得多。

3.1 什么是 ComposeTestRule?

ComposeTestRule 是 Compose UI 测试的「入口」。你可以把它理解成一个轻量级的测试宿主——它负责在测试环境中启动 Compose 运行时,渲染你的 Composable 函数,并提供一系列用于查找、断言、交互的 API。

我个人习惯把 ComposeTestRule 比作「测试沙箱」。你不需要启动真机上的 Activity,也不需要处理 Fragment 生命周期。它自己就能跑一个 Compose 树,快得很。

核心要点:

  • ComposeTestRule 是 @get:Rule 声明的 JUnit Rule
  • 它管理 Compose 的 Composition 生命周期
  • 所有 UI 查找和断言都通过它来触发

3.2 createComposeRule:创建测试规则

在 Compose 测试中,最常用的就是 createComposeRule()。它返回一个 ComposeTestRule 实例,我们通常把它声明为测试类的属性,并用 @get:Rule 注解标记。

嗯,这里要注意:@get:Rule 是 Kotlin 属性委托的写法,别写成 @Rule 直接放字段上,那样 JUnit 4 可能不认。

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testGreeting() {
        // 测试代码写在这里
    }
}

为什么用 @get:Rule?因为 JUnit 4 的 Rule 机制要求字段是 public 的,而 Kotlin 的属性默认是 private 的。用 @get:Rule 相当于告诉 JUnit:「嘿,这个属性的 getter 就是 Rule 对象」。我曾经在这上面踩过坑,测试一直不执行,查了半天才发现是注解位置写错了。

小技巧:如果你需要测试多个 Composable 或者需要更精细地控制 Composition 的销毁时机,可以用 createAndroidComposeRule<ComponentActivity>()。它会在真实的 Activity 中运行 Compose,适合集成度更高的测试场景。

3.3 setContent:往测试环境里塞 UI

有了 ComposeTestRule,下一步就是告诉它:「我要测哪个 Composable」。用 setContent 方法就行。它的参数是一个 Composable lambda,你可以在里面调用任何 Composable 函数。

@Test
fun testGreeting() {
    composeTestRule.setContent {
        Greeting(name = "Android")
    }
}

就这么简单。setContent 会触发 Compose 的首次组合,把你的 UI 渲染到测试环境中。之后你就可以用各种查找和断言方法来验证 UI 了。

我记得刚开始写 Compose 测试时,总担心 setContent 会不会很慢。后来发现它比启动一个 Activity 快了一个数量级。因为 Compose 的渲染是轻量级的,不需要走 AMS、WMS 那一套流程。

注意:setContent 只能在测试方法内部调用,不能在 @Before 里提前调用。因为每个测试方法都需要独立的 Composition 环境。如果你在 @Before 里调了 setContent,后续测试可能会互相污染。

3.4 基本断言:检查 UI 对不对

UI 渲染出来了,怎么验证它?ComposeTestRule 提供了一套基于语义树的查找和断言 API。最常用的就是 onNodeWithTextassertExists / assertIsDisplayed

@Test
fun testGreeting() {
    composeTestRule.setContent {
        Greeting(name = "Android")
    }

    composeTestRule
        .onNodeWithText("Hello, Android!")
        .assertExists()
}

这段代码的意思是:在 Compose 树中查找文本为 "Hello, Android!" 的节点,然后断言它存在。如果找不到,测试就会失败。

除了 assertExists,还有几个常用的断言方法:

断言方法 作用
assertExists() 断言节点存在(至少一个匹配)
assertDoesNotExist() 断言节点不存在
assertIsDisplayed() 断言节点可见(在屏幕上)
assertIsNotDisplayed() 断言节点不可见
assertTextEquals() 断言节点的文本内容完全匹配
assertTextContains() 断言节点的文本包含指定子串

你想想看,这些断言方法覆盖了 UI 测试中最常见的场景:元素有没有、显不显示、文字对不对。我项目中大部分 UI 测试,80% 的断言都用的是上面这几个。

3.5 查找节点的多种方式

除了 onNodeWithText,ComposeTestRule 还提供了其他查找方式。因为有时候你不想用文本匹配——比如文本是动态的,或者有多个相同文本的节点。

// 通过 contentDescription 查找
composeTestRule.onNodeWithContentDescription("用户头像")

// 通过 tag 查找(需要 Composable 设置 Modifier.testTag)
composeTestRule.onNodeWithTag("login_button")

// 通过语义属性查找(更灵活)
composeTestRule.onNode(hasText("提交") and hasClickAction())

我个人最推荐用 testTag。为什么呢?因为文本可能会变(国际化、动态数据),但 tag 是开发者和测试者约定好的「暗号」,稳定可靠。我在项目中就吃过文本匹配的亏——产品经理某天把「提交」改成了「确认提交」,测试用例全红了。

最佳实践:给关键交互元素(按钮、输入框、列表项)加上 Modifier.testTag("xxx")。测试代码用 onNodeWithTag 来定位,这样 UI 文案怎么改都不影响测试。

3.6 一个完整的测试示例

把上面这些串起来,写一个完整的测试:

class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loginButton_shouldBeDisplayed() {
        composeTestRule.setContent {
            LoginScreen()
        }

        composeTestRule
            .onNodeWithTag("login_button")
            .assertIsDisplayed()
    }

    @Test
    fun errorMessage_shouldNotExist_initially() {
        composeTestRule.setContent {
            LoginScreen()
        }

        composeTestRule
            .onNodeWithText("用户名或密码错误")
            .assertDoesNotExist()
    }
}

你看,两个测试方法,各自独立。第一个检查登录按钮是否显示,第二个检查错误提示是否默认隐藏。结构清晰,职责单一。

3.7 本章知识体系图

下面这张图帮你梳理 ComposeTestRule 的核心流程:

ComposeTestRule 核心流程 createComposeRule() 声明 @get:Rule setContent { } 渲染 Composable 查找节点 onNodeWith* onNodeWithText onNodeWithTag onNodeWithCD onNode(语义) 断言验证 assertExists / assertIsDisplayed / assertTextEquals

3.8 避坑指南

最后,分享几个我实际踩过的坑:

  • 忘记加 @get:Rule——测试方法直接跳过,不报错也不执行。排查方法:在测试方法里加个断点,看进不进得来。
  • setContent 放在 @Before 里——多个测试方法共享同一个 Composition,状态互相影响。我曾经因为这个导致一个测试明明该失败却通过了。
  • 用 assertExists 代替 assertIsDisplayed——节点存在不代表它可见。比如被 Modifier.alpha(0f) 隐藏的节点,assertExists 仍然通过。如果你要验证用户能不能看到,用 assertIsDisplayed
  • 文本匹配太死板——"Hello, Android!" 和 "Hello, Android!"(全角感叹号)是两个不同的字符串。建议用 assertTextContains 或者统一用 testTag。

好了,这一章的内容就到这里。你现在应该能写出一个完整的 Compose UI 测试了——创建规则、设置内容、查找节点、断言验证。下一章我们会深入语义树,看看 Compose 到底是怎么「看到」你的 UI 的。


公众号:蓝海资料掘金营,微信deep3321