网站CSS重构怕翻车,咋用视觉快照测试来保平安?

6,559字
28–42 分钟
in

改样式这事儿,手一抖就是满屏bug,尤其是一堆老代码堆了几个月要重构,心里真没底。这次聊一个实打实的法子:用Playwright自带的视觉快照功能,给页面拍个“证件照”,改完一对比就知道有没有搞坏布局。

目录

啥是快照测试

说白了就是拿浏览器把页面截屏存下来当基准,下次跑测试时再截一张,像素级比对。但凡哪个按钮位置变了、颜色不对、甚至某个字体多了一个像素,测试立马报红。跟人工肉眼对比比,这玩意儿虽然有点娇气(比如加载慢、动画没停、随机内容捣乱),但胜在不用盯着屏幕看到眼花。

Playwright里就一个方法:toHaveScreenshot()。调用它,框架会自动生成一张.png图存到本地,后续运行就拿着新截图跟这张老图杠上。

搭环境

从零搓一个视觉测试环境,不依赖项目本身的依赖库。单独建个文件夹test/visual,所有东西塞里头,免得给主项目加包袱。

新建package.json,写上这几个脚本:

{
  "scripts": {
    "test": "playwright test",
    "report": "playwright show-report",
    "update": "playwright test --update-snapshots",
    "reset": "rm -r ./playwright-report ./test-results ./viz.test.js-snapshots || true"
  },
  "devDependencies": {
    "@playwright/test": "^1.49.1"
  }
}

update脚本是神级操作——当确定新改的样式是对的,跑一下它,所有基准截图就批量刷新了。reset则一键清空截图和报告,适合想从头来一遍。

然后终端执行:

npm install
npx playwright install

第一行装依赖,第二行下载一堆无头浏览器(Firefox、Chrome、Safari)。如果觉得占硬盘,可以只装一个,比如加参数--with-deps chromium

配浏览器和服务器

创建playwright.config.js,告诉测试跑在哪个浏览器、访问哪个地址。因为静态网站需要起个本地服务,用Python的http.server最省事:

import { defineConfig, devices } from "@playwright/test";

let BROWSERS = ["Desktop Firefox", "Desktop Chrome", "Desktop Safari"];
let BASE_URL = "http://localhost:8000";
let SERVER = "cd ../../dist && python3 -m http.server";

let IS_CI = !!process.env.CI;

export default defineConfig({
  testDir: "./",
  fullyParallel: true,
  forbidOnly: IS_CI,
  retries: 2,
  workers: IS_CI ? 1 : undefined,
  reporter: "html",
  webServer: {
    command: SERVER,
    url: BASE_URL,
    reuseExistingServer: !IS_CI
  },
  use: {
    baseURL: BASE_URL,
    trace: "on-first-retry"
  },
  projects: BROWSERS.map(ua => ({
    name: ua.toLowerCase().replaceAll(" ", "-"),
    use: { ...devices[ua] }
  }))
});

注意webServer这块:每次跑测试前自动执行python3 -m http.server,根目录指向../../dist(即主项目编译后的文件夹)。如果电脑没Python,换成npx serve ../../dist也行。reuseExistingServer设为true能省下反复重启服务的时间。

还有个小坑:testDir: "./"表示当前目录找测试文件,所以待会写的viz.test.js就放在同级。

写第一个快照用例

新建sample.test.js,内容极简:

import { test, expect } from "@playwright/test";

test("home page", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveScreenshot();
});

执行npm test。第一次跑必然失败,因为没基准图。但Playwright会贴心地弹出一句话:没有找到快照,已生成新快照。再跑一次npm test,就绿了。

这时候去文件夹里翻,能看到多出一个sample.test.js-snapshots目录,里头躺着home-page-desktop-firefox.png之类的图片。这些就是“证件照”了。

故意破坏一下:去dist里改个按钮的圆角或者背景色,重新跑测试。终端会显示差异图,甚至给出一个网页报告(执行npm run report就能打开)。报告里左右滑动对比,差在哪一目了然。

给所有页面自动生成测试

手写每个页面的用例太蠢了。写个小爬虫,遍历网站所有链接,动态生成测试。

先在playwright.config.js里加上全局设置,并导出要用到的变量:

export let BROWSERS = ["Desktop Firefox", "Desktop Chrome", "Desktop Safari"];
export let BASE_URL = "http://localhost:8000";

export default defineConfig({
  // ... 前面的配置保留
  globalSetup: require.resolve("./setup.js")
});

创建setup.js,负责在测试开始前跑一遍爬虫,把URL列表存成sitemap.json

import { BASE_URL, BROWSERS } from "./playwright.config.js";
import { createSiteMap, readSiteMap } from "./sitemap.js";
import playwright from "@playwright/test";

export default async function globalSetup(config) {
  try {
    readSiteMap();
    return;
  } catch(err) {}

  let browser = playwright.devices[BROWSERS[0]].defaultBrowserType;
  browser = await playwright[browser].launch();
  let page = await browser.newPage();
  await createSiteMap(BASE_URL, page);
  await browser.close();
}

然后写sitemap.js。假设网站有个索引页/topics,里面所有<a>链接都是要测的页面:

import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

let ENTRY_POINT = "/topics";
let SITEMAP = join(__dirname, "./sitemap.json");

export async function createSiteMap(baseURL, page) {
  await page.goto(baseURL + ENTRY_POINT);
  let urls = await page.evaluate(extractLocalLinks, baseURL);
  let data = JSON.stringify(urls, null, 4);
  writeFileSync(SITEMAP, data, { encoding: "utf-8" });
}

export function readSiteMap() {
  try {
    var data = readFileSync(SITEMAP, { encoding: "utf-8" });
  } catch(err) {
    if(err.code === "ENOENT") {
      throw new Error("missing site map");
    }
    throw err;
  }
  return JSON.parse(data);
}

function extractLocalLinks(baseURL) {
  let urls = new Set();
  let offset = baseURL.length;
  for(let { href } of document.links) {
    if(href.startsWith(baseURL)) {
      let path = href.slice(offset);
      urls.add(path);
    }
  }
  return Array.from(urls);
}

核心是page.evaluate(extractLocalLinks, baseURL)——这段代码直接跑到浏览器里执行,拿到所有链接后回传给Node。

最后创建真正的测试文件viz.test.js,动态生成用例:

import { readSiteMap } from "./sitemap.js";
import { test, expect } from "@playwright/test";

let sitemap = [];
try {
  sitemap = readSiteMap();
} catch(err) {
  test("site map", ({ page }) => {
    throw new Error("missing site map");
  });
}

for(let url of sitemap) {
  test(`page at ${url}`, async ({ page }) => {
    await page.goto(url);
    await expect(page).toHaveScreenshot();
  });
}

第一次跑之前先手动执行一次setup.js?不用,globalSetup会自动跑。但注意如果sitemap.json不存在,readSiteMap抛异常,那个test("site map")会作为一个占位用例失败,提醒先去生成地图。实际上globalSetup会在发现没地图时自动爬一次,所以正常情况不会走到这个分支。

处理烦人的随机性和动效

视觉测试最怕两样东西:动态内容和加载延迟。比如轮播图、时间戳、或者:visited链接变色,都会导致截图对不上。

Playwright允许注入自定义CSS来压制这些幺蛾子。改一下viz.test.js

import { join } from "node:path";

let OPTIONS = {
  stylePath: join(__dirname, "./viz.tweaks.css")
};

// 循环里的测试改成:
for(let url of sitemap) {
  test(`page at ${url}`, async ({ page }) => {
    await page.goto(url);
    await expect(page).toHaveScreenshot(OPTIONS);
  });
}

新建viz.tweaks.css,写点“作弊”样式:

/* 强制所有已访问链接颜色跟普通链接一样 */
main a:visited {
  color: var(--color-link);
}

/* 藏掉内嵌的随机demo iframe */
iframe[src$="/articles/signals-reactivity/demo.html"] {
  visibility: hidden;
}

/* 针对某个特殊页面,隐藏表格最后一行第一列(因为内容会动态变) */
body:has(h1 a[href="/wip/unicode-symbols/"]) {
  main tbody > tr:last-child > td:first-child {
    font-size: 0;
    visibility: hidden;
  }
}

:has()在这里很香,可以根据页面特定标题来定向打补丁,不影响其他页面。这种方式比在测试代码里写一堆if-else清爽多了。

截全屏而不是只截视口

默认toHaveScreenshot()只截浏览器当前可视区域。如果页面很长,滚动后才能看到的内容压根没比到,重构改了底部样式也不会报错——这坑踩得血亏。

解决方案:先获取页面完整高度,然后临时把视口调成那个高度,再截图。

playwright.config.js里加两个常量:

export let WIDTH = 800;
export let HEIGHT = WIDTH;

然后在projects里设置默认视口:

  projects: BROWSERS.map(ua => ({
    name: ua.toLowerCase().replaceAll(" ", "-"),
    use: {
      ...devices[ua],
      viewport: {
        width: WIDTH,
        height: HEIGHT
      }
    }
  }))

修改viz.test.js里的循环逻辑,写一个辅助函数:

import { WIDTH, HEIGHT } from "./playwright.config.js";

for(let url of sitemap) {
  test(`page at ${url}`, async ({ page }) => {
    await checkSnapshot(url, page);
  });
}

async function checkSnapshot(url, page) {
  await page.setViewportSize({ width: WIDTH, height: HEIGHT });
  await page.goto(url);
  await page.waitForLoadState("networkidle");

  let height = await page.evaluate(() => {
    return document.documentElement.getBoundingClientRect().height;
  });

  await page.setViewportSize({ width: WIDTH, height: Math.ceil(height) });
  await page.waitForLoadState("networkidle");
  await expect(page).toHaveScreenshot(OPTIONS);
}

waitForLoadState("networkidle")会等到网络请求空闲半秒,对懒加载图片比较友好。但要注意:如果页面有不停轮询的接口,它会一直等不到空闲,这时候换成"load"或者手动waitForTimeout更稳妥。

全屏截图还有个副作用:性能开销大,页面特别长(比如几千像素)容易超时。可以给toHaveScreenshot传个timeout参数,比如{ timeout: 30000 }。或者在配置里全局调高expect的超时。

更新基准图的正确姿势

当CSS重构做完、确认新样式没问题后,需要批量更新所有基准截图。执行:

npm run update

它会重新跑一遍测试,并且用新截图覆盖老的。记得跑之前先确认本地服务开着,而且网站内容确实是你想要的新样子。

万一更新错了想回滚?Git大法好。把*-snapshots/文件夹纳入版本控制,每次更新前先提交一次,翻车了直接git checkout回来。

针对不同浏览器各截各的图

配置里写了三个浏览器,每个都会产生一套独立的截图。文件夹里会出现home-page-desktop-chrome.pnghome-page-desktop-firefox.png等。这样做的好处是能发现跨浏览器布局差异——比如某个flex在Safari里折行了,Chrome里却没折,测试直接报警。

但代价是时间翻三倍。如果只是验证重构不破坏布局,只测一个Chromium就够了。把BROWSERS数组改成["Desktop Chrome"],跑得快。

还有一种折中:本地开发只测Chrome,CI里再跑全量。通过process.env.CI环境变量控制BROWSERS列表就行。