18、截图测试与视觉回归:Screenshot测试工具
说实话,截图测试是我在团队里推广得最费劲、但回报也最明显的一项实践。我记得第一次跟产品经理说「我们要做截图测试」时,对方一脸懵——「截图?那不是手动点一下就行吗?」嗯,后来他亲眼看到一次UI重构后,十几个页面同时出现像素级偏差,才明白这玩意儿有多重要。
今天我们就来聊聊截图测试与视觉回归。说白了,就是让机器替你的眼睛干活,自动对比「这次改完的界面」和「上次正确的界面」有没有差异。
为什么需要截图测试?
你想想看,传统的单元测试能测逻辑、测状态、测数据流,但它测不了「这个按钮是不是往左偏了2像素」。UI测试虽然能点一点,但很难发现「背景色从#FFFFFF变成了#FEFEFE」这种细微变化。
截图测试正好补上这个缺口。它的核心逻辑很简单:
- 先拍一张「黄金截图」——也就是你确认没问题的版本
- 每次改完代码,再拍一张新的
- 两张图逐像素对比,有差异就报错
我在项目中遇到过最典型的场景:设计师改了一个间距值,从16dp改成14dp,开发改完觉得「差不多」。结果截图测试一跑,十几个列表项全部标红。嗯,这就是视觉回归的价值。
主流工具:Shot vs Paparazzi
Android生态里,截图测试工具主要有两派。我两个都用过,各有各的脾气。
| 特性 | Shot | Paparazzi |
|---|---|---|
| 运行方式 | 需要模拟器或真机 | 纯JVM运行,无需设备 |
| 截图速度 | 较慢(依赖设备渲染) | 极快(纯CPU渲染) |
| 对比精度 | 像素级 | 像素级 |
| 适用场景 | 完整UI、系统交互 | 组件级、快速反馈 |
| CI友好度 | 需要GPU支持 | 无特殊要求 |
我个人习惯是:组件级别的截图用Paparazzi,页面级别的截图用Shot。为什么?Paparazzi跑得快,改一行代码几秒钟就能看到结果,适合开发阶段的快速迭代。Shot更接近真实设备,适合上线前的最终验证。
配置截图测试:以Shot为例
咱们直接上手。先加依赖:
// build.gradle (app module)
plugins {
id 'com.karumi.shot' version '5.14.0'
}
android {
// 确保testBuildType是debug
testBuildType = "debug"
}
dependencies {
androidTestImplementation 'com.karumi:shot:5.14.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
然后写一个截图测试用例。嗯,这里要注意:截图测试通常放在androidTest目录下,因为它需要真实的Activity环境。
// ProfileScreenScreenshotTest.kt
@RunWith(AndroidJUnit4::class)
class ProfileScreenScreenshotTest {
@get:Rule
val activityRule = ActivityScenarioRule(ProfileActivity::class.java)
@Test
fun captureProfileScreen() {
// 等待数据加载
onView(withId(R.id.profile_container))
.check(matches(isDisplayed()))
// 截图并对比
screenshot("profile_screen_default")
}
}
第一次运行会生成黄金截图,存在app/src/test/screenshots目录下。之后每次运行都会自动对比。
测试UI一致性:不只是像素对比
很多人以为截图测试就是「两张图叠一起看有没有不同」。其实没那么简单。我在项目中遇到过几个坑:
- 字体渲染差异:不同Android版本对字体的渲染有细微差别,尤其是中文字体。解决方案是统一使用自定义字体,或者在测试设备上固定系统字体。
- 动画干扰:如果截图时动画还没结束,结果就是两张完全不同的图。我习惯在截图前加一个
Thread.sleep(500),或者用Animator.setDuration(0)禁用动画。 - 系统UI遮挡:状态栏、导航栏的高度在不同设备上不一样。建议截图时用
ActivityScenario设置全屏模式。
Animator.setDurationScale(0f)。
处理不同屏幕尺寸
这是截图测试里最头疼的问题。一张截图在1080p上通过,换到720p上就全红了。怎么办?
我的做法是:按屏幕尺寸分组管理黄金截图。
// 在测试中指定设备配置
@Test
fun captureProfileScreenOnPhone() {
// 使用小屏设备配置
activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
screenshot("profile_screen_phone")
}
@Test
fun captureProfileScreenOnTablet() {
// 使用大屏设备配置
activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
screenshot("profile_screen_tablet")
}
然后在CI上,我会配置多个模拟器,分别跑不同尺寸的截图测试。每个尺寸都有自己的黄金截图目录。
CI集成截图测试
截图测试不上CI,基本等于白做。为什么?因为本地跑过了,别人一改又坏了。CI能保证每次提交都自动检查。
以GitHub Actions为例,配置很简单:
# .github/workflows/screenshot.yml
name: Screenshot Tests
on: [pull_request]
jobs:
screenshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
- name: Run Shot screenshot tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
arch: x86_64
script: ./gradlew executeScreenshotTests -Pshot.testFormat=PNG
- name: Upload screenshot diffs
if: failure()
uses: actions/upload-artifact@v3
with:
name: screenshot-diffs
path: app/build/reports/shot/**/*.png
这里有个关键点:失败时一定要上传差异图。这样PR的审查者能直接看到「哪里不一样」,不用自己再跑一遍。
-gpu swiftshader_indirect,或者直接用Paparazzi这种纯CPU方案。
知识体系总览
下面这张图是我自己整理的截图测试知识体系,你可以对照着看看自己掌握了哪些:
从这张图能看出来,截图测试不是孤立的技术点。它从工具选型开始,经过配置、核心能力落地,最后一定要跟CI打通,才能形成闭环。
写在最后
截图测试是我在团队里推得最成功的质量保障手段之一。它不像单元测试那么「抽象」,也不像UI自动化测试那么「脆弱」。一张图摆在那,过没过一目了然。
当然,它也有缺点——维护成本高、对设备敏感、偶尔会有误报。但如果你能做好尺寸分组、处理好动画和字体差异,它带来的收益绝对值得投入。
嗯,今天就聊到这。下次你在PR里看到截图测试全绿通过时,那种感觉,真的很爽。
公众号:蓝海资料掘金营,微信deep3321