UI测试:Compose UI测试与Espresso集成测试
说实话,很多Android开发者在写UI测试时都会犯怵。我刚开始带团队那会儿,大家普遍觉得「功能跑通就行了,写UI测试太费时间」。结果呢?每次改个按钮颜色都能引发连锁崩溃,线上事故频发。后来我痛定思痛,把UI测试纳入了CI流程,这才算真正稳住了质量。
今天我们就来聊聊UI测试。我会把Compose UI测试和Espresso集成测试掰开揉碎了讲。你想想看,这两套东西其实各有各的脾气,但核心目标都一样——保证用户界面按预期工作。
为什么UI测试这么重要?
单元测试能验证你的ViewModel逻辑对不对,但UI层的问题它管不了。比如:
- 按钮点击后页面跳转了吗?
- 网络请求失败时,错误提示显示了吗?
- 列表滑动到底部时,加载更多触发了吗?
这些问题,只有UI测试能回答。我在项目中遇到过最典型的例子:一个TextView的字体颜色在深色模式下看不清,单元测试全绿,但用户反馈炸了锅。从那以后,我要求每个新功能必须附带UI测试用例。
Compose UI测试:新时代的玩法
如果你用的是Jetpack Compose,那UI测试的体验会比XML时代舒服很多。Compose测试框架是官方专门为声明式UI设计的,API更简洁,语义更清晰。
环境配置
首先,在build.gradle里加上依赖:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.0"
注意第二个依赖,它只在debug模式下生效。我刚开始就踩过这个坑——release包跑测试直接崩溃,查了半天才发现是漏了这个manifest库。
写一个简单的Compose测试
假设我们有一个登录按钮:
@Composable
fun LoginButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("登录")
}
}
对应的测试代码:
@RunWith(AndroidJUnit4::class)
class LoginButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testLoginButton_displaysCorrectText() {
composeTestRule.setContent {
LoginButton(onClick = { })
}
composeTestRule
.onNodeWithText("登录")
.assertExists()
}
}
这里的关键是createComposeRule(),它会帮你管理Compose的生命周期。我个人习惯把setContent放到@Before里,避免每个测试方法都重复写。
交互测试
光检查文本还不够,我们得模拟用户点击:
@Test
fun testLoginButton_clickTriggersAction() {
var clicked = false
composeTestRule.setContent {
LoginButton(onClick = { clicked = true })
}
composeTestRule
.onNodeWithText("登录")
.performClick()
assertTrue(clicked)
}
嗯,这里要注意:performClick()是同步的,它会等待UI刷新完毕再返回。如果你需要等待异步操作,可以用waitForIdle()或者waitUntil()。
onNodeWithTag()配合semantics { testTag = "item_$index" },比用文本定位更稳定。文本可能因为国际化而改变,但tag不会。
Espresso集成测试:老牌劲旅
如果你的项目还是传统的View系统,或者混合了Compose和View,那Espresso依然是主力。Espresso的核心理念是「让测试像用户一样操作UI」。
基础用法
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun testLoginButton_clickNavigatesToHome() {
onView(withId(R.id.btn_login))
.perform(click())
onView(withId(R.id.tv_welcome))
.check(matches(isDisplayed()))
}
}
Espresso的API很直观:onView找到控件,perform执行操作,check验证结果。但有个坑——withId在多个布局中有相同ID时会报AmbiguousViewMatcherException。我曾经在一个复杂的Fragment里调试了半天,最后发现是两个不同布局用了同一个ID。
等待异步操作
Espresso默认会等待主线程空闲。但如果你用了自定义的异步任务,可能需要IdlingResource:
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()
}
}
说实话,IdlingResource的写法有点啰嗦。我个人更推荐用ComposeTestRule的waitUntil,或者干脆把异步逻辑封装到ViewModel里,用LiveData/Flow来驱动UI,这样测试时只需要观察状态变化就行。
onWebView()的专用API,要么干脆跳过WebView的UI测试,只测原生部分。
Compose与Espresso的混合测试
现在很多项目是Compose和View混用的。比如一个Activity里既有Compose组件,又有传统的Fragment。这时候怎么测?
官方提供了ComposeTestRule和ActivityScenarioRule的混合方案:
@RunWith(AndroidJUnit4::class)
class HybridTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule
val composeTestRule = createAndroidComposeRule(MainActivity::class.java)
@Test
fun testHybridInteraction() {
// 用Espresso操作传统View
onView(withId(R.id.btn_legacy))
.perform(click())
// 用Compose规则检查Compose组件
composeTestRule
.onNodeWithText("来自Compose的响应")
.assertExists()
}
}
注意:createAndroidComposeRule会绑定到Activity,所以两个规则共享同一个Activity实例。我建议把Compose测试作为主规则,因为它的API更现代,而且能自动处理Compose的生命周期。
测试策略与最佳实践
UI测试不是越多越好。我见过有些团队写了上千个UI测试,结果每次构建都要跑40分钟。合理的策略应该是:
| 测试类型 | 覆盖范围 | 执行频率 |
|---|---|---|
| 关键路径测试 | 登录、注册、支付等核心流程 | 每次提交 |
| 边界条件测试 | 空数据、网络错误、权限拒绝 | 每日构建 |
| 回归测试 | 所有历史功能 | 每周或发布前 |
另外,我强烈建议把UI测试放到CI的「快速反馈」阶段。说白了就是:提交代码后5分钟内,必须知道UI测试是否通过。如果等半小时才出结果,开发者早切到其他任务了,修复成本会高很多。
知识体系总览
下面这张图概括了本章的核心内容,你可以把它当作UI测试的「作战地图」:
从图中可以看出,Compose和Espresso各有侧重,但最终都服务于同一个目标:保证UI质量。混合测试是过渡期的好方案,但长期来看,我建议新项目直接全量使用Compose,测试体验会统一很多。
好了,UI测试这块的内容就讲到这里。记住:测试不是负担,而是你代码的「安全带」。下次改需求时,先跑一遍UI测试,你会感谢自己当初多花的那半小时。