3、创建第一个Compose快照测试:@Test注解、@Composable函数、基线快照生成

说实话,很多人刚开始接触快照测试时,都以为它特别复杂。其实不然。你想想看,咱们平时写单元测试,测的是逻辑对不对。而快照测试要解决的是另一个问题:UI长什么样? 特别是Compose这种声明式UI,一个状态变化,整个组件树都可能重绘。你肉眼看不出来的细微布局偏移,快照测试一眼就能抓住。

我记得自己第一次在项目里引入快照测试时,团队里有人质疑:“这玩意儿不就是截图对比吗?能有多靠谱?”嗯,后来他亲眼看着一次重构导致按钮间距变了2dp,而快照测试直接标红失败,才彻底服了。下面我就带你走一遍完整的流程——从写第一个测试方法,到生成基线快照。

3.1 添加测试依赖

在开始之前,先把依赖配好。项目中通常用 robolectric 跑本地测试,不需要真机。我建议直接在 build.gradle.kts(module 级别)里加上:

dependencies {
    // 核心快照库
    testImplementation("com.facebook.screenshot:screenshot-core:0.0.1-alpha01")
    // Compose 集成
    testImplementation("com.facebook.screenshot:screenshot-compose:0.0.1-alpha01")
    // Robolectric(用于本地跑 Compose 测试)
    testImplementation("org.robolectric:robolectric:4.12")
    // 别忘了 JUnit
    testImplementation("junit:junit:4.13.2")
}

小贴士: 版本号建议查一下最新稳定版。我当时用了一个旧版,结果测试死活跑不起来,最后发现是 snapshot 库和 Compose BOM 版本不匹配。所以这里建议保持 Compose BOM 与 screenshot 库同步更新。

3.2 编写第一个快照测试

测试代码放在 src/test/java/ 下,而不是 androidTest。因为我们要用 Robolectric 模拟 Android 环境,不需要真机或模拟器。

先看一个最简单的例子。假设我们有一个组件叫 Greeting,它显示一个名字:

@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello, $name!",
        style = MaterialTheme.typography.headlineMedium
    )
}

对应的快照测试怎么写?我习惯直接创建一个测试类:

class GreetingScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun greeting_snapshot() {
        // 1. 设置 Composable 内容
        composeTestRule.setContent {
            Greeting(name = "World")
        }

        // 2. 执行快照(生成 / 比对)
        composeTestRule.waitForIdle() // 等待动画/布局完成
        // 这里靠 screenshot 库的扩展函数来拍照
    }
}

等等,这段代码里还没有真正的快照断言。别急——下面才是关键。

3.3 使用 @Test + @Composable 生成基线快照

上面我只是搭建了架子。真正的快照测试,需要调用类似 captureComposable() 或 screenshot() 的方法来生成一张图片,并与之前保存的“基线”对比。不过不同库的 API 略有差异。我这里拿 Roborazzi(一个很流行的开源方案)来举例,因为它简洁且被广泛使用。

首先引入 Roborazzi:

testImplementation("io.github.takahirom.roborazzi:roborazzi:1.30.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.30.0")

然后修改测试:

class GreetingScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun greeting_snapshot_baseline() {
        composeTestRule.setContent {
            Greeting(name = "World")
        }

        // 关键行:对比或记录快照
        composeTestRule.onRoot().captureRoboImage("baseline_greeting.png")
    }
}

首次运行这个测试时,Roborazzi 会生成一张 baseline_greeting.png 并保存在 src/test/assets/screenshots/ 下。这张就是 基线快照。此后每次运行,它都会对比渲染结果和基线图片的像素差异。

要点总结:

  • @Test 注解告诉 JUnit 这是一个测试方法。
  • @Composable 函数是我们要测试的 UI 组件。
  • captureRoboImage() 执行快照生成/比对。
  • 首次运行 → 生成基线;后续运行 → 对比基线。

3.4 基线快照的存储与更新

基线文件默认放在 src/test/assets/screenshots/ 目录。你可以手动删除它来强制重新生成新的基线。但更规范的做法是:当 UI 故意变更时(比如设计师改了颜色),你需要在测试运行前更新基线。

Roborazzi 提供了一个系统属性开关:

// 在 gradle.properties 或命令行设置
roborazzi.record=true

当 roborazzi.record=true 时,所有 captureRoboImage 会覆盖已有基线图片,而不是对比。你可以在 CI 上用一个专门的 job 来更新基线,或者本地跑一下。

我曾经踩过的坑: 团队里有人提交了错误的基线图片到 Git(比如裁剪不全的),结果后续所有人都基于错误基线开发,导致大量假阴性失败。后来我强制将基线目录加入到 .gitignore 的例外中,并且规定合并到主分支前必须在 CI 上重新录制基线。这样就能保证基线永远对应最新的代码。

3.5 测试运行流程全景

下图展示了整个过程的交互关系:从测试代码到基线生成,再到对比结果。你可以把它当作一张挂图放在手边。

第一个Compose快照测试流程 测试类 @Test + @Composable Composable函数 Greeting("World") captureRoboImage 生成/对比快照 首次运行 生成基线图片 后续运行 与基线对比 结果通过 ✅ 无像素差异 结果失败 ❌ 像素差异超出阈值 📁 基线存储位置:src/test/assets/screenshots/ 更新基线:gradle.properties 加 roborazzi.record=true

3.6 一个完整的实战示例

我们把所有内容组合成一个完整的测试文件,方便你直接参考:

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test

// 被测试的 Composable
@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello, $name!",
        style = MaterialTheme.typography.headlineMedium
    )
}

// 快照测试类
class GreetingScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun greeting_default() {
        composeTestRule.setContent {
            Greeting(name = "World")
        }
        composeTestRule.onRoot().captureRoboImage("greeting_default.png")
    }

    @Test
    fun greeting_long_name() {
        composeTestRule.setContent {
            Greeting(name = "Supercalifragilisticexpialidocious")
        }
        composeTestRule.onRoot().captureRoboImage("greeting_long_name.png")
    }

    @Test
    fun greeting_empty_name() {
        composeTestRule.setContent {
            Greeting(name = "")
        }
        composeTestRule.onRoot().captureRoboImage("greeting_empty_name.png")
    }
}

运行方式:

./gradlew testDebugUnitTest

第一次会把三张基线图片生成到 src/test/assets/screenshots/ 下。之后每次跑都会有对比。如果某次重构导致 UI 变化,测试就会红掉,告诉你具体哪张图不一样。

3.7 常见误区 & 避坑指南

问题说明
测试跑在 Android 下还是 JVM 下? 一定要用 Robolectric 在 JVM 下跑,否则没法做 CI 离线对比。确保测试类放在 src/test/java/ 而不是 src/androidTest/java/。
基线图片提交到 Git 吗? 必须提交!基线是测试的“标准答案”。如果不提交,换一台电脑就无法对比。建议放在 src/test/assets/screenshots/ 并加入版本控制。
如何忽略细微差异? Roborazzi 可以设置 roborazzi.threshold=0.02(像素差异阈值)。这个我需要提醒你——设太大可能漏掉真实问题,设太小容易误报。我一般用 0.01~0.02。
UI 改了如何更新基线? 添加 roborazzi.record=true 重新运行,然后手动检查生成的图片是否是你想要的。确认后再把这条配置去掉,提交新的基线。

我曾经犯过的错: 有一次跑完测试直接提交了基线,没仔细看生成图片里混入了状态栏时间。后来发现是因为测试没加 waitForIdle(),截图时动画还没结束。从此我养成了一个习惯:每次录制基线后,手动打开图片看一眼再提交。哪怕花 10 秒,也比后续排查假阴性节省一小时。

3.8 小结

好了,到这里你已经掌握了创建第一个 Compose 快照测试的全部要点。说白了就是三步:

  1. 写一个 @Test 方法,里面用 composeTestRule.setContent { ... } 塞入你的 Composable。
  2. 调用 captureRoboImage("文件名.png") 生成/对比快照。
  3. 首次运行自动生成基线,后续每次运行都会自动对比。