Jetpack Compose 测试:让UI测试不再头疼
说实话,很多Android开发者对UI测试都有点怵。我以前也是,总觉得写UI测试又慢又麻烦,还不如手动点两下。直到有一次,我在一个电商项目里改了个小小的间距,结果导致三个页面布局错乱——测试同事提了bug我才发现。嗯,从那以后,我就老老实实把Compose测试捡起来了。
今天咱们就来聊聊Jetpack Compose的测试。别担心,Compose的测试框架比传统的View系统友好太多了。你想想看,不用再写一堆findViewById,不用再等异步回调,Compose测试框架直接操作语义树,干净利落。
Compose UI测试框架:到底测什么?
Compose测试框架的核心思路很简单:不关心你怎么画,只关心你画了什么。说白了,它不测像素级渲染,而是测UI的语义行为。
举个例子,你写了个Button,上面显示"提交"。传统测试要等布局加载完,找到这个Button,再点击。Compose测试呢?直接找"提交"这个文本,找到就点。背后的布局怎么变,测试代码基本不用改。
核心依赖:在build.gradle里加上这几行
// 模块级 build.gradle
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
注意第二行,ui-test-manifest只在debug模式下生效。它会在你的测试APK里自动注册一个Instrumentation,没有它ComposeTestRule跑不起来。我刚开始就漏了这个,折腾了半天。
语义树与测试标签:Compose测试的"眼睛"
Compose测试不依赖View的id,而是依赖语义树(Semantics Tree)。每个Composable在渲染时,都会生成一个语义节点。节点里包含了文本、角色、状态等信息。
你可以把语义树想象成UI的"无障碍版本"。测试框架就是通过这棵树来定位元素的。
给组件打标签
有时候光靠文本定位不够。比如列表里有两个"删除"按钮,你总得区分一下。这时候就需要testTag。
// 在Composable里
Text(
text = "删除",
modifier = Modifier.testTag("delete_button_1")
)
// 在测试里
composeTestRule.onNodeWithTag("delete_button_1").performClick()
我个人习惯给所有可交互的组件都加上testTag。哪怕现在只有一个,也加上。为什么?因为需求会变。我曾经有个项目,一开始只有一个"提交"按钮,后来加了第二个,测试代码全得改。从那以后,我养成了"先打标签再写UI"的习惯。
小技巧:testTag最好用常量定义,别到处写字符串。不然改起来想哭。
ComposeTestRule的使用:测试的"遥控器"
ComposeTestRule是测试的核心入口。它负责启动Composable、查找节点、执行操作、验证状态。
创建规则
@get:Rule
val composeTestRule = createComposeRule()
// 或者用AndroidComposeTestRule(需要Activity)
// @get:Rule
// val composeTestRule = createAndroidComposeRule<MainActivity>()
createComposeRule()是最常用的。它不需要Activity,直接启动一个Composable。测试速度更快,也更聚焦。
常用操作
| 方法 | 作用 |
|---|---|
onNodeWithText("文本") |
按文本找节点 |
onNodeWithTag("标签") |
按testTag找节点 |
onNode(hasText("xxx")) |
用匹配器找节点 |
.performClick() |
点击 |
.performTextInput("内容") |
输入文本 |
.assertIsDisplayed() |
断言可见 |
.assertTextEquals("xxx") |
断言文本 |
看个完整例子:
@Test
fun testSubmitButton() {
composeTestRule.setContent {
MyScreen()
}
// 找到"提交"按钮
composeTestRule.onNodeWithText("提交")
.assertIsDisplayed()
.performClick()
// 验证点击后出现"提交成功"
composeTestRule.onNodeWithText("提交成功")
.assertIsDisplayed()
}
是不是比传统UI测试清爽多了?没有findViewById,没有onView,直接按语义找。
动画与异步操作的测试:别让动画拖慢你
动画和异步操作是UI测试的两大"坑"。你想想看,测试代码跑得飞快,但UI还在做动画,或者网络请求还没回来——这时候去断言,肯定失败。
处理动画
Compose测试框架默认会自动等待动画结束。但有时候动画太长,或者你根本不想等。我建议在测试配置里关掉动画:
// 在测试类的companion object或@Before里
@Before
fun setUp() {
composeTestRule.mainClock.autoAdvance = false
}
关了自动推进后,你可以手动控制时间:
// 推进100毫秒
composeTestRule.mainClock.advanceTimeBy(100L)
// 推进到动画结束
composeTestRule.mainClock.advanceTimeByFrame()
我曾经遇到过一个情况:一个渐变动画持续2秒,测试里没关自动推进,结果每次跑测试都要等2秒。几十个测试用例下来,光等动画就花了一分多钟。后来统一在@Before里关了动画,测试时间直接砍半。
注意:关掉自动推进后,记得在需要的地方手动推进时间。不然动画永远停在第一帧,断言也会失败。
处理异步操作
异步操作比如网络请求、数据库读写,在测试里怎么处理?
我的做法是:用假数据代替真实调用。比如用Mockito或FakeRepository,让异步操作立刻返回结果。
// 假设ViewModel里有个网络请求
class MyViewModel(private val repo: Repository) {
fun loadData() {
viewModelScope.launch {
val result = repo.fetchData() // 可能耗时
_state.value = result
}
}
}
// 测试里用FakeRepository
class FakeRepository : Repository {
override suspend fun fetchData(): Result {
return Result.Success("假数据")
}
}
@Test
fun testLoadData() {
val viewModel = MyViewModel(FakeRepository())
composeTestRule.setContent {
MyScreen(viewModel)
}
// 直接断言,因为FakeRepository是同步返回的
composeTestRule.onNodeWithText("假数据")
.assertIsDisplayed()
}
如果实在要用真实异步,可以用waitUntil系列方法:
// 等待某个条件成立
composeTestRule.waitUntil(5000) {
composeTestRule.onNodeWithText("加载完成")
.isDisplayed()
}
但说实话,我很少这么用。等待超时会让测试变慢,而且不稳定。能用假数据就用假数据。
知识体系总览
下面这张图总结了Compose测试的核心脉络:
避坑指南
最后分享几个我踩过的坑:
- 测试标签重复:同一个页面里testTag不能重复,否则框架不知道找哪个。我建议用"页面_组件_序号"的命名方式。
- 别忘了setContent:每个测试方法都要先调用
composeTestRule.setContent,不然节点树是空的。我刚开始经常忘,然后对着空树找半天节点。 - 异步操作别硬等:能用假数据就用假数据。硬等不仅慢,还容易因为超时而失败。
- 多设备适配:不同屏幕尺寸下,某些组件可能被折叠或隐藏。测试时最好用
assertExists而不是assertIsDisplayed,前者只检查节点是否存在,不检查可见性。
好了,Compose测试的核心内容就这些。说白了就是三件事:用语义树定位、用ComposeTestRule操作、处理好动画和异步。掌握了这些,你的UI测试就能跑得又快又稳。