UI测试:Compose UI测试与Espresso集成测试

说实话,很多Android开发者在写UI测试时都会犯怵。我刚开始带团队那会儿,大家普遍觉得「功能跑通就行了,写UI测试太费时间」。结果呢?每次改个按钮颜色都能引发连锁崩溃,线上事故频发。后来我痛定思痛,把UI测试纳入了CI流程,这才算真正稳住了质量。

今天我们就来聊聊UI测试。我会把Compose UI测试和Espresso集成测试掰开揉碎了讲。你想想看,这两套东西其实各有各的脾气,但核心目标都一样——保证用户界面按预期工作。

为什么UI测试这么重要?

单元测试能验证你的ViewModel逻辑对不对,但UI层的问题它管不了。比如:

  • 按钮点击后页面跳转了吗?
  • 网络请求失败时,错误提示显示了吗?
  • 列表滑动到底部时,加载更多触发了吗?

这些问题,只有UI测试能回答。我在项目中遇到过最典型的例子:一个TextView的字体颜色在深色模式下看不清,单元测试全绿,但用户反馈炸了锅。从那以后,我要求每个新功能必须附带UI测试用例。

核心原则: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()

小技巧:测试列表或LazyColumn时,用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的写法有点啰嗦。我个人更推荐用ComposeTestRulewaitUntil,或者干脆把异步逻辑封装到ViewModel里,用LiveData/Flow来驱动UI,这样测试时只需要观察状态变化就行。

避坑指南:我曾经在测试一个包含WebView的页面时,Espresso一直报超时。后来发现WebView的加载不在主线程控制范围内。解决方案是:要么用onWebView()的专用API,要么干脆跳过WebView的UI测试,只测原生部分。

Compose与Espresso的混合测试

现在很多项目是Compose和View混用的。比如一个Activity里既有Compose组件,又有传统的Fragment。这时候怎么测?

官方提供了ComposeTestRuleActivityScenarioRule的混合方案:

@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测试时,不要一上来就要求100%覆盖。先挑3-5个最核心的流程写测试,让大家尝到甜头。等大家发现「改代码再也不怕改崩了」,自然就会主动加测试。

知识体系总览

下面这张图概括了本章的核心内容,你可以把它当作UI测试的「作战地图」:

UI测试知识体系 Compose UI测试 Espresso集成测试 createComposeRule / setContent onNodeWithText / performClick waitForIdle / waitUntil ActivityScenarioRule onView(withId) / perform(click) IdlingResource / 异步等待 混合测试:Compose + Espresso 关键路径测试 回归测试

从图中可以看出,Compose和Espresso各有侧重,但最终都服务于同一个目标:保证UI质量。混合测试是过渡期的好方案,但长期来看,我建议新项目直接全量使用Compose,测试体验会统一很多。


好了,UI测试这块的内容就讲到这里。记住:测试不是负担,而是你代码的「安全带」。下次改需求时,先跑一遍UI测试,你会感谢自己当初多花的那半小时。

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