第51章:UI测试——Espresso、Compose UI测试、IdlingResource、截图测试
UI测试,说白了就是让机器代替你的眼睛和手指,去验证界面好不好用。
我刚开始做Android开发时,总觉得UI测试是浪费时间——"界面我手动点两下不就行了?"直到有一次,一个按钮在特定机型上偏移了2个像素,测试没发现,上线后被用户骂惨了。嗯,从那以后,我再也不敢轻视UI测试了。
51.1 为什么需要UI测试?
你想想看,一个App可能有几十个页面,几百个交互点。每次改完代码,手动把所有页面点一遍?不现实。而且人的注意力有限,很容易漏掉边界情况。
UI测试能帮你做到三件事:
- 回归验证:改了一个地方,确保其他地方没坏
- 自动化执行:CI流水线里跑一遍,省心省力
- 截图对比:像素级检查,肉眼看不出的差异也能发现
核心观点:UI测试不是"要不要做"的问题,而是"怎么做才高效"的问题。
51.2 Espresso:传统View系统的UI测试
如果你还在用传统的XML布局,Espresso就是你的首选。它是Google官方推荐的UI测试框架,API设计得很优雅。
51.2.1 基本用法
先看一个最简单的例子:
@Test
fun testButtonClick() {
// 找到按钮并点击
onView(withId(R.id.btn_submit))
.perform(click())
// 验证结果
onView(withId(R.id.tv_result))
.check(matches(withText("提交成功")))
}
这段代码做了三件事:找元素、执行操作、验证结果。Espresso的API就是围绕这三个步骤设计的。
个人习惯:我一般会把常用的查找条件封装成扩展函数,比如fun onButton(id: Int) = onView(withId(id)),这样测试代码会更简洁。
51.2.2 匹配器(Matcher)
Espresso的匹配器非常灵活。除了用withId,你还可以:
withText("登录")— 匹配文本hasDescendant(withText("标题"))— 匹配子元素isDisplayed()— 匹配可见元素allOf(withId(R.id.tv_name), withText("张三"))— 组合条件
我在项目中遇到过一个问题:同一个页面有两个相同id的TextView,用withId会找到两个。后来我用allOf(withId(R.id.tv_name), withText("张三"))才精确锁定目标。
51.2.3 异步等待与IdlingResource
这是Espresso里最容易踩坑的地方。为什么?因为UI测试是同步执行的,但网络请求、数据库操作是异步的。
举个例子:
// 点击加载按钮
onView(withId(R.id.btn_load)).perform(click())
// 直接验证结果 —— 这里会失败!
onView(withId(R.id.tv_data)).check(matches(withText("加载完成")))
为什么会失败?因为点击按钮后,网络请求还没回来,TextView还是旧数据。Espresso不会自动等待异步操作完成。
解决方案就是IdlingResource。它告诉Espresso:"嘿,现在有个异步任务在跑,你先等着,别急着检查UI。"
class NetworkIdlingResource : IdlingResource {
private var isIdle = true
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = "NetworkIdlingResource"
override fun isIdleNow() = isIdle
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
this.callback = callback
}
fun setBusy() {
isIdle = false
}
fun setIdle() {
isIdle = true
callback?.onTransitionToIdle()
}
}
我曾经踩过的坑:忘记在@After方法里反注册IdlingResource,导致后续测试全部卡死。记住:注册和反注册一定要成对出现。
51.3 Compose UI测试
如果你已经切换到Jetpack Compose,那测试方式就完全不一样了。Compose的测试API更贴近声明式UI的思想。
51.3.1 基本用法
@Test
fun testComposeButton() {
composeTestRule.setContent {
MyScreen()
}
// 找到按钮并点击
composeTestRule.onNodeWithText("提交")
.performClick()
// 验证结果
composeTestRule.onNodeWithText("提交成功")
.assertIsDisplayed()
}
注意看,Compose测试用的是composeTestRule,而不是onView。查找方式也变成了onNodeWithText、onNodeWithTag等。
51.3.2 语义树(Semantics Tree)
Compose的测试核心是语义树。每个Composable组件都会生成一个语义节点,测试框架就是通过遍历这棵树来找到目标元素的。
你可以给组件添加testTag来方便查找:
Button(
onClick = { /* ... */ },
modifier = Modifier.testTag("submit_button")
) {
Text("提交")
}
// 测试中
composeTestRule.onNodeWithTag("submit_button")
.performClick()
我建议:给所有可交互的组件都加上testTag。虽然onNodeWithText也能用,但文本可能会变,而tag是稳定的。
51.3.3 Compose中的异步处理
Compose测试默认会等待重组完成,所以大部分异步场景不需要IdlingResource。但如果你用了协程,还是要注意:
@Test
fun testComposeAsync() = runTest {
composeTestRule.setContent {
MyAsyncScreen()
}
// 等待协程完成
advanceUntilIdle()
composeTestRule.onNodeWithText("加载完成")
.assertIsDisplayed()
}
51.4 截图测试
截图测试是UI测试的"终极武器"。它不关心具体哪个TextView显示了什么文本,而是直接对比两张图片——一张是预期截图,一张是实际截图。
51.4.1 为什么需要截图测试?
你想想看,有些UI问题是很难用代码验证的:
- 字体大小不对
- 颜色偏差
- 布局偏移了2个像素
- 阴影效果缺失
这些用matches很难描述,但截图对比一目了然。
51.4.2 常用工具
| 工具 | 适用场景 | 特点 |
|---|---|---|
| Roborazzi | Compose + View | 轻量、支持Gradle缓存 |
| Shot | 传统View | 基于Screenshot测试,社区活跃 |
| Paparazzi | Compose | 纯JVM运行,不需要模拟器 |
我个人比较喜欢Roborazzi,因为它同时支持Compose和传统View,而且集成起来很简单。
51.4.3 基本用法(以Roborazzi为例)
@Test
fun testScreenshot() {
composeTestRule.setContent {
MyScreen()
}
// 截图并对比
composeTestRule.onRoot()
.captureRoboImage("screenshots/my_screen.png")
}
第一次运行会生成基准截图。之后每次运行都会对比,如果差异超过阈值,测试就会失败。
注意:截图测试对环境很敏感。不同分辨率、不同系统版本、不同字体大小,都可能产生差异。我建议在CI环境里统一模拟器配置,否则截图对比会频繁"误报"。
51.5 知识体系总览
下面这张图总结了本章的核心内容:
51.6 避坑指南
最后,分享几个我踩过的坑:
- 测试太脆弱:不要依赖具体的文本内容做匹配。文本可能会国际化,用
testTag更稳定。 - 忽略异步:Espresso测试里,只要涉及网络、数据库、协程,一定要用IdlingResource或
advanceUntilIdle。 - 截图测试的"假阳性":不同设备、不同字体大小都会导致截图对比失败。统一测试环境是关键。
- 测试跑得太慢:UI测试比单元测试慢得多。我建议把UI测试放在单独的CI job里,不要和单元测试混在一起。
我的经验:UI测试的投入产出比很高。花一天时间写好测试,后面每次改代码都能省下半小时的手动测试时间。一个月下来,这笔账怎么算都划算。
UI测试不是银弹,但它能帮你守住最后一道防线。尤其是截图测试,那种"一眼看出问题"的爽快感,用过就回不去了。
公众号:蓝海资料掘金营,微信deep3321