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测试的核心脉络:

Compose UI测试知识体系 语义树 ComposeTestRule 异步与动画 onNodeWithText onNodeWithTag performClick assertIsDisplayed mainClock waitUntil 测试核心原则 不测像素,测语义行为 用假数据代替真实异步 关掉动画,手动控制时间

避坑指南

最后分享几个我踩过的坑:

  • 测试标签重复:同一个页面里testTag不能重复,否则框架不知道找哪个。我建议用"页面_组件_序号"的命名方式。
  • 别忘了setContent:每个测试方法都要先调用composeTestRule.setContent,不然节点树是空的。我刚开始经常忘,然后对着空树找半天节点。
  • 异步操作别硬等:能用假数据就用假数据。硬等不仅慢,还容易因为超时而失败。
  • 多设备适配:不同屏幕尺寸下,某些组件可能被折叠或隐藏。测试时最好用assertExists而不是assertIsDisplayed,前者只检查节点是否存在,不检查可见性。

好了,Compose测试的核心内容就这些。说白了就是三件事:用语义树定位、用ComposeTestRule操作、处理好动画和异步。掌握了这些,你的UI测试就能跑得又快又稳。

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