用Visual Testing解决前端UI回归难题

司马曌煜 工具 阅读 2,099
赞 19 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周上线了个新功能,改了几行 CSS,结果 QA 甩过来三张截图:按钮错位、文字溢出、布局崩了。我寻思这不就改了个 padding 吗?怎么还影响这么大?

用Visual Testing解决前端UI回归难题

于是决定上视觉回归测试(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 通知等,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论