改样式这事儿,手一抖就是满屏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.png、home-page-desktop-firefox.png等。这样做的好处是能发现跨浏览器布局差异——比如某个flex在Safari里折行了,Chrome里却没折,测试直接报警。
但代价是时间翻三倍。如果只是验证重构不破坏布局,只测一个Chromium就够了。把BROWSERS数组改成["Desktop Chrome"],跑得快。
还有一种折中:本地开发只测Chrome,CI里再跑全量。通过process.env.CI环境变量控制BROWSERS列表就行。
