用Visual Testing解决前端UI回归难题
先看效果,再看代码
上周上线了个新功能,改了几行 CSS,结果 QA 甩过来三张截图:按钮错位、文字溢出、布局崩了。我寻思这不就改了个 padding 吗?怎么还影响这么大?
于是决定上视觉回归测试(Visual Testing),别再靠人眼对比了。折腾了一天,总算把整套流程跑通了,亲测有效,今天直接上干货。
核心工具是 Playwright + Jest-image-snapshot。为什么选它?因为能跟现有 E2E 测试共用一套脚本,不用额外维护 UI 驱动逻辑。而且支持多浏览器截图比对,关键——配置简单,适合我们这种不想搞太重的团队。
// playwright.config.js
module.exports = {
use: {
baseURL: 'https://jztheme.com',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
};
// visual.test.js
const { chromium } = require('playwright');
const { toMatchImageSnapshot } = require('jest-image-snapshot');
expect.extend({ toMatchImageSnapshot });
describe('视觉回归测试', () => {
let browser;
let page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
});
afterEach(async () => {
await page.close();
});
it('首页渲染应无异常', async () => {
await page.goto('/');
await page.waitForLoadState('networkidle'); // 等待静态资源加载完成
const screenshot = await page.screenshot({
fullPage: true,
});
expect(screenshot).toMatchImageSnapshot({
failureThreshold: 0.01, // 允许1%像素差异
failureThresholdType: 'percent',
customDiffConfig: {
threshold: 0.1, // 像素对比敏感度
},
noColors: false,
});
});
});
上面这段代码就是最核心的部分。跑起来之后,第一次执行会生成“基准图”(snapshot),后续每次 CI 跑测试都会拿当前截图和基准图对比。如果有明显变化,测试失败,你会在控制台看到类似这样的提示:
Expected image to match or be a close match of snapshot.
Diff saved to: __diff_output__/visual.test.js-homepage-diff.png
然后你就能去查那个 diff.png 文件,看看到底哪里变了。是不是某个 margin 搞崩了布局,或者字体没加载出来导致高度不对。
这个场景最好用
说实话,不是所有页面都适合做视觉测试。我一开始傻乎乎地给每个路由都加了截图,结果 CI 跑得巨慢,而且频繁误报。
后来总结出几个真正值得上的场景:
- 设计稿还原度高的页面,比如营销页、落地页
- 组件库的 Demo 预览页,特别是那些用了复杂 CSS 的组件(Dropdown、Modal、Table)
- 核心业务流程的关键节点,比如结算页、表单提交页
这些地方一旦 UI 出问题,用户体验直接拉胯。其他动态内容多的页面,比如带用户头像列表的后台首页,反而不适合——数据变来变去,截图根本没法稳定比对。
举个实际例子,我们有个订单确认页,结构固定但样式复杂,用了不少自定义阴影和圆角。我就专门给它写了条测试:
it('订单确认页样式应正常', async () => {
await page.route('**/api/user/orders*', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(require('./mocks/order-response.json')),
})
);
await page.goto('/checkout/confirm');
await page.waitForSelector('.order-summary');
const screenshot = await page.screenshot();
expect(screenshot).toMatchImageSnapshot({
customSnapshotsDir: '__image_snapshots__/checkout',
customSnapshotIdentifier: 'confirm-page',
});
});
这里我用了 page.route 拦截 API 请求,返回固定数据。不然每次用户不同,订单号、金额都不一样,截图肯定对不上。这点一定要注意,动态数据是视觉测试的大敌。
踩坑提醒:这三点一定注意
以下是我踩过最狠的三个坑,建议直接抄作业避免:
1. 字体渲染差异导致误报
本地跑得好好的,CI 上突然挂了。排查半天发现是 Linux 容器里少了中文字体,页面 fallback 到了 serif 字体,导致行高和换行位置全变了。
解决方案是在 Docker 环境安装常用字体:
# 在 CI 的 runner 里执行
apt-get update && apt-get install -y fonts-noto-cjk fonts-liberation
fc-cache -f -v # 刷新字体缓存
或者更简单的办法:测试时强制使用本地已有的英文字体,避开中文渲染问题:
/* 测试环境下注入 */
body.devtools-vt {
font-family: Arial, sans-serif !important;
}
// 在测试前注入样式
await page.addStyleTag({
content: 'body { font-family: Arial, sans-serif !important; }',
});
2. 时间/日期类内容动态变化
有次首页轮播图下面显示“今日更新”,每天字不一样,截图天天变。后来用 page.evaluate 直接清空这类元素:
await page.evaluate(() => {
const el = document.querySelector('.dynamic-date');
if (el) el.innerText = '2023-01-01'; // 固定值
});
或者干脆隐藏掉不影响主结构的动态区块:
await page.$$eval('.ads-placeholder, .live-timestamp', els =>
els.forEach(el => (el.style.display = 'none'))
);
3. 图片懒加载导致截图空白
有些图片在视窗外,Playwright 截图时还没加载出来,结果比对失败。解决方法是提前触发加载:
// 强制加载所有 img
await page.evaluate(() => {
document.querySelectorAll('img').forEach(img => {
if (img.dataset.src) img.src = img.dataset.src;
img.scrollIntoView();
});
});
await page.waitForTimeout(500); // 给点加载时间
或者更稳妥的方式:等特定图片 load 事件触发。
高级技巧:局部截图 vs 全页截图
一开始我都用 fullPage: true,结果稍微滚动条宽度有点差异就报错。后来改成只截关键区域,稳定多了。
const element = await page.$('.main-content');
const screenshot = await element.screenshot();
expect(screenshot).toMatchImageSnapshot();
这样哪怕页头广告位换了图,只要主体内容不变,测试就不该失败。另外还可以并列测试多个模块:
test('检查三个核心模块', async () => {
await page.goto('/dashboard');
const header = await page.$('.page-header');
const chart = await page.$('#sales-chart');
const table = await page.$('.data-table');
expect(await header.screenshot()).toMatchImageSnapshot({ customSnapshotIdentifier: 'header' });
expect(await chart.screenshot()).toMatchImageSnapshot({ customSnapshotIdentifier: 'chart' });
expect(await table.screenshot()).toMatchImageSnapshot({ customSnapshotIdentifier: 'table' });
});
拆得越细,定位问题越快。虽然写起来多几行,但长期来看省心。
别指望100%自动化,接受一点小误差
我最开始设 failureThreshold: 0,想做到完全一致。结果每次 Chrome 版本升级、字体抗锯齿微调都会导致失败。后来妥协到 0.01(1% 差异允许),反而更实用了。
还有些情况注定没法完美处理,比如 Canvas 图表每次渲染坐标可能有亚像素级偏移。这时候只能手动 review diff 图,确认是否真有问题。
所以我的建议是:把视觉测试当作“辅助报警器”,而不是“绝对判官”。它帮你发现肉眼容易忽略的问题,但最终还得人来看一眼。
结语
这套方案上线两周,已经拦住了两次明显的样式回退。虽然 setup 花了一天,但现在每次 PR 都能自动验证关键页面,值了。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 GitHub Actions 自动生成 diff 预览图、接入 Slack 通知等,后续会继续分享这类博客。

暂无评论