3、ComposeTestRule入门:createComposeRule、setContent、基本断言
好,我们正式开始写测试了。
前面两章我们聊了为什么要做测试,以及测试的分类。现在,是时候把手弄脏了——写第一行 Compose UI 测试代码。
这一章的核心就三个东西:createComposeRule、setContent、基本断言。说白了,就是「怎么启动一个 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。最常用的就是 onNodeWithText 和 assertExists / 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 的核心流程:
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