16、快照测试:Roborazzi入门、截图对比、基线管理
快照测试,说白了就是给UI拍张照片,然后拿它跟之前拍的照片对比。如果两张照片不一样,测试就挂了。这玩意儿在Compose里特别有用——你想想看,Compose的UI全是代码拼出来的,一个padding改错了,整个布局可能就歪了。传统的单元测试只能检查「按钮是否存在」,但快照测试能检查「按钮看起来对不对」。
我个人习惯把快照测试当作UI测试的最后一道防线。逻辑测试过了,交互测试过了,最后拍张照确认一下视觉上没问题。嗯,今天我们就来聊聊Roborazzi这个工具,它是目前Android圈子里做快照测试最趁手的家伙。
为什么需要快照测试?
我在项目中遇到过好几次这样的场景:改了一个颜色值,结果某个深藏不露的页面背景全变了。单元测试跑得绿油油的,但UI就是不对劲。为什么?因为单元测试只检查「值对不对」,不检查「看起来对不对」。
快照测试能帮你抓住这些漏网之鱼:
- 视觉回归:代码改了,UI变了,测试立刻报警
- 跨版本对比:同一个组件在不同系统版本上长啥样,一目了然
- 团队协作:PR里带上截图对比,reviewer一眼就能看出问题
- 文档即测试:生成的截图可以直接贴到设计文档里
核心思想:快照测试不是要替代单元测试,而是补上「视觉验证」这块拼图。两者配合使用,才能做到真正的质量保障。
Roborazzi入门
Roborazzi是专门为Android设计的快照测试库,支持Compose、View、甚至是整个Activity的截图。它的用法非常简洁,我直接上代码。
添加依赖
首先在项目的根build.gradle里加上插件:
// 根目录 build.gradle.kts
plugins {
id("io.github.takahirom.roborazzi") version "1.8.0" apply false
}
然后在app模块里启用:
// app/build.gradle.kts
plugins {
id("io.github.takahirom.roborazzi")
}
android {
// 确保test构建类型启用了
testBuildType = "debug"
}
dependencies {
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.8.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.8.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.8.0")
}
小提示:Roborazzi依赖Robolectric,所以你的测试跑在JVM上,不需要模拟器。速度比真机测试快得多。
第一个快照测试
写一个最简单的Compose快照测试:
@RunWith(AndroidJUnit4::class)
class ProfileCardSnapshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun profileCard_snapshot() {
composeTestRule.setContent {
ProfileCard(
name = "张三",
avatar = "https://example.com/avatar.png",
bio = "资深Android工程师"
)
}
// 拍一张截图
composeTestRule.onRoot().captureRoboImage("snapshots/profile_card.png")
}
}
跑一次测试,Roborazzi会在build/reports/roborazzi目录下生成截图。第一次跑的时候,这张图就是「基线」(baseline)。以后每次跑测试,它都会拿新截图跟基线对比。
截图对比:到底比什么?
Roborazzi的对比不是简单的像素级对比——那样太死板了。它用的是结构相似性(SSIM)算法,能容忍一些微小的差异,比如抗锯齿导致的像素偏差。
对比结果有三种:
| 结果 | 含义 | 操作 |
|---|---|---|
| ✅ 通过 | 新截图与基线一致 | 无需处理 |
| ⚠️ 差异 | 存在视觉差异 | 检查差异报告,确认是否预期 |
| ❌ 失败 | 差异超过阈值 | 要么修复代码,要么更新基线 |
差异报告长什么样?Roborazzi会生成一张对比图,左边是基线,右边是新截图,中间用红色高亮标出差异区域。我曾经靠这个抓到过一个bug——某个按钮的圆角半径被改成了0,肉眼几乎看不出来,但对比图上一片红。
注意:默认的差异阈值是0.0,也就是零容忍。但在实际项目中,我建议设成0.01或0.02,给抗锯齿和字体渲染留点余量。不然每次换JDK版本,测试都可能飘红。
// 设置差异阈值
@get:Rule
val roborazziRule = RoborazziRule(
options = RoborazziRule.Options(
threshold = 0.02f // 允许2%的差异
)
)
基线管理:截图版本控制
基线文件就是你的「标准答案」。它们通常存在项目的src/test/assets/roborazzi目录下,或者你自定义的路径。这些文件必须提交到Git里,不然别人拉下来代码跑测试,没有基线可对比。
基线管理的核心原则就一条:基线只能由人工确认后更新。绝对不能自动覆盖。
什么时候更新基线?
- UI设计变更:设计师改了样式,你改了代码,截图自然要更新
- 修复了视觉bug:之前截图有瑕疵,现在修好了,基线要跟着变
- 依赖库升级:Material3升级了,按钮样式变了,基线得重新拍
如何更新基线?
Roborazzi提供了两种方式:
- 命令行参数:跑测试时加上
-P roborazzi.record=true,所有截图都会覆盖基线 - Gradle任务:执行
./gradlew recordRoborazziDebug,专门用来录制基线
// 录制基线
./gradlew recordRoborazziDebug
// 验证基线(跑测试但不录制)
./gradlew verifyRoborazziDebug
我的习惯:在CI上只跑verifyRoborazziDebug,不允许自动录制。开发者本地改完UI后,手动跑一次录制,然后把新的基线文件提交到PR里。这样reviewer就能看到「哦,这个PR改了UI,基线也更新了」。
知识体系总览
下面这张图把快照测试的核心流程串起来了:
实际项目中的避坑指南
快照测试看着简单,但用起来坑不少。我踩过的几个坑,分享给你:
坑一:动态内容导致截图不一致
如果你的UI里有当前时间、随机数、网络图片,每次截图都不一样。怎么办?
- 时间:注入固定的时间戳,或者用
Clock.fixed() - 网络图片:用Coil的
FakeImageLoader,返回一张占位图 - 随机数:用
Random(seed)固定种子
// 固定时间
@Test
fun snapshot_withFixedTime() {
val fixedClock = Clock.fixed(
Instant.parse("2024-01-01T00:00:00Z"),
ZoneOffset.UTC
)
Clock.withClock(fixedClock) {
composeTestRule.setContent {
TimestampDisplay()
}
composeTestRule.onRoot().captureRoboImage("snapshots/timestamp.png")
}
}
坑二:字体渲染差异
不同操作系统、不同JDK版本,字体渲染结果可能不一样。我遇到过在macOS上截图通过,在Linux CI上截图失败的情况。
解决方案:在测试里指定字体,或者用Roborazzi的fontScale选项统一缩放比例。
composeTestRule.onRoot().captureRoboImage(
"snapshots/profile_card.png",
options = RoborazziOptions(
fontScale = 1.0f // 固定字体缩放
)
)
坑三:基线文件膨胀
每个截图文件大概几十KB到几百KB,如果测试用例多了,Git仓库会变得很胖。我的做法是:
- 只保留必要的截图,不要每个状态都拍
- 用Git LFS管理基线文件
- 定期清理不再使用的基线
曾经踩过的坑:有一次我忘了把基线文件加入.gitignore的某个目录,结果CI上每次跑测试都重新生成截图,导致基线被覆盖。排查了半天才发现是构建缓存的问题。后来我养成了习惯:在.gitignore里明确排除build/目录下的所有截图,只保留assets/roborazzi里的基线。
总结
Roborazzi让快照测试变得特别简单——写个Compose测试,拍张照,剩下的交给工具去对比。但工具再简单,也得靠人来把控基线更新的时机。记住一句话:基线是标准答案,不是自动生成的产物。
我个人建议你在项目里先挑几个核心组件做快照测试,比如登录页、个人主页、列表卡片。跑通了流程,再逐步铺开。别一上来就给所有页面都加上,那样基线文件管理起来会让人崩溃。
好了,快照测试就聊到这儿。去试试Roborazzi吧,你会发现它抓视觉bug的能力,比肉眼靠谱多了。
公众号:蓝海资料掘金营,微信deep3321