主题
给博客加自动化测试:抓到 3 个真 bug,和 7 个测试自己的坑
这篇不是教程搬运。所有代码都来自本站
tests/e2e/,所有 bug 都是真实发生的,包括我自己写错的那些。
前段时间我把这个博客改版了一遍:统一品牌色、重构文章列表、加 RSS 和 sitemap、定制 404 页。改完之后有个尴尬的事实——大部分改动我没法确认真的生效了。构建通过不代表页面对,DOM 里有元素不代表用户看得见。
于是补了一套 Playwright E2E 测试。第一次完整跑,23 通过、10 失败。这 10 个失败里,3 个是真 bug,7 个是测试自己写错了。
这篇记录这个过程,顺便回答几个常见问题:用例怎么设计、按钮怎么测、组件怎么测、接口怎么测、覆盖率怎么看。
其中第二节「用例怎么设计」是我认为最值得先读的——决定测什么,比会用什么 API 重要得多。
一、先说抓到的 3 个真 bug
bug 1:日期显示成了 Sat Jul 18
首页文章列表的日期,本该是 2026-07-19,实际渲染成 Sat Jul 18。
原因在数据加载器里。YAML 会把 date: 2026-07-19 解析成 JavaScript 的 Date 对象,而我写的是:
ts
date: String(frontmatter.date).slice(0, 10)String(new Date(...)) 得到的是 "Sat Jul 19 2026 08:00:00 GMT+0800",取前 10 个字符正好是 Sat Jul 18——格式错了,还因为时区偏移退了一天。
正确写法:
ts
function formatDate(raw: unknown): string {
if (!raw) return ''
if (raw instanceof Date) return raw.toISOString().slice(0, 10)
return String(raw).slice(0, 10)
}抓到它的测试很朴素:
ts
await expect(post.locator('.post-meta')).toContainText(/\d{4}-\d{2}-\d{2}/)为什么之前没发现:改版时我验证过"首页有 5 篇文章",数量对了就以为没问题。只数个数不看内容,是断言写得太浅的典型。
bug 2:定制的 404 页从来没生效过
我写了一个中文 404 组件,在主题入口这样注册:
ts
export default {
extends: DefaultTheme,
Layout() { /* ... */ },
NotFound // ← 看起来很合理
}测试报错说找不到「页面走丢了」。打开一看,显示的是 VitePress 自带的英文 PAGE NOT FOUND。
翻源码才明白。VitePress 默认主题的 VPContent.vue 里写死了:
vue
<slot name="not-found" v-if="page.isNotFound"><NotFound /></slot>它 import 了自己的 NotFound 组件,只通过一个名为 not-found 的插槽对外暴露。一旦你的 Layout 包了 DefaultTheme.Layout,顶层导出的 NotFound 就永远走不到渲染路径上。
正确写法是走插槽:
ts
Layout() {
return h(DefaultTheme.Layout, null, {
'layout-top': () => h(ReadingProgress),
'not-found': () => h(NotFound)
})
}这个 bug 最阴险的地方是它静默失败——没有报错、没有警告,页面照常显示一个看起来挺正常的 404。不测就永远不知道。
bug 3:对比度不达标,而且是我自己警告过的那条
改版时我把品牌色统一成暖橙,手算了对比度:#B0542A 对白底 5.0:1,通过 WCAG AA。我还特意在注释里写了:#E8835A 只有 2.7:1,只能用于装饰,不能承载文字。
然后 axe 扫出来:
| 元素 | 前景 / 背景 | 对比度 |
|---|---|---|
| hero 按钮 | #ffffff on #E8835A | 2.68 ❌ |
| 按钮 hover | #ffffff on #D4694A | 3.54 ❌ |
| 标签 chip | #B0542A on #FCEEE8 | 4.46 ❌ |
| 行内 code | #B0542A on #E7E9EC | 4.16 ❌ |
两个问题:
- 我写完"此色不可承载文字",转头就让 VitePress 拿它当了实心按钮的底色配白字。
- 我算的 5.0:1 是对纯白底。但同一个
--vp-c-brand-1还会落在标签的浅橙底和行内code的浅灰底上,在那两处只有 4.46 和 4.16。
这条最能说明为什么要自动化:手算只覆盖你想得到的组合,axe 扫描的是页面上每一处真实渲染的前景/背景对。
修复是把品牌色加深到 #9C4A24(三种底色下都 ≥5:1),并显式接管按钮变量而不是让它继承:
css
--vp-button-brand-bg: #9c4a24;
--vp-button-brand-text: #ffffff;暗色模式则反过来——亮橙底配深色字,对比度 8.2:1,比深底白字更好读。
二、用例怎么设计
上面三个 bug 抓到之后,我回头想了一件事:为什么是这三个被抓到,而不是别的? 答案不在于我写了多少测试,而在于一开始决定测什么。
这节讲的是那个决定过程。
从「坏了会怎样」倒推,别从代码正推
最容易走的弯路,是打开项目文件夹照着文件列表写测试:有 posts.data.mts 就测数据加载,有 ReadingProgress.vue 就测进度条……这样能写出一大堆测试,但它们的分布反映的是代码的组织方式,不是风险的分布。
换个方向问三个问题:
- 这里坏了,谁会疼?
- 坏了之后,多久会被发现?
- 发现的时候,损失多大?
本站的清单就是这么来的:
| 关键流程 | 谁会疼 | 多久发现 | 损失 |
|---|---|---|---|
| 首页/索引页列出全部文章 | 所有读者 | 可能很久 | 新文章白写 |
| 文章正文渲染 | 所有读者 | 很快 | 站点等于挂了 |
| 搜索能找到并跳转 | 老读者 | 很久 | 存量内容被埋 |
cleanUrls 与旧 .html 都可达 | 外部链接来的人 | 几乎不会 | 存量链接全 404 |
| RSS 字段完整、时间倒序 | 订阅者 | 几乎不会 | 订阅端静默异常 |
| 暗色模式与对比度 | 部分用户 | 几乎不会 | 读不清,但没人会来告诉你 |
| 减少动效偏好 | 前庭敏感用户 | 几乎不会 | 生理不适 |
注意第三列。
优先测「不吵的失败」
我这次抓到的三个 bug 有个共同点:它们都不报错。
- 404 页显示的是英文默认页,看起来挺正常
- RSS 字段缺失,阅读器只会显示一个空白行
- 对比度不足,只有部分用户读着费劲,而且他们不会来反馈
对比一下会自己叫的失败:构建报错、JS 抛异常、页面白屏。这些不写测试也会在五分钟内被发现——你自己点一下就撞上了。
所以我的排序原则是:
测试资源优先投给「坏了但不吵」的地方。
会吵的失败已经有了免费的检测手段(你自己、你的用户、构建日志)。不吵的失败没有任何人在盯,只有测试在盯。
这也解释了为什么我给一个个人博客写了无障碍测试——不是因为要拿合规证书,而是因为对比度问题是所有失败里最安静的一种。
断言要断在用户能感知的那一层
bug 1(日期错乱)暴露的是断言深度问题。改版时我其实验证过首页,写的是"有 5 篇文章"——数量对了就收工。
断言有个深度阶梯:
ts
await expect(el).toBeAttached() // 1. 存在于 DOM
await expect(el).toBeVisible() // 2. 用户看得见
await expect(el).toContainText(/\d{4}-\d{2}-\d{2}/) // 3. 内容是对的
await expect(page).toHaveURL(/\/notes\//) // 4. 流程走通了停在第 1、2 层的断言,只能证明「渲染没崩」,不能证明「渲染对了」。 日期渲染成 Sat Jul 18 时,前两层全部通过。
实用做法是每写一条断言就问一句:这条如果通过了,用户看到的东西一定是对的吗? 我现在的写法:
ts
for (const post of await posts.all()) {
await expect(post.locator('a')).not.toBeEmpty() // 标题非空
await expect(post.locator('.post-desc')).not.toBeEmpty() // 描述非空
await expect(post.locator('.post-meta')).toContainText(/\d{4}-\d{2}-\d{2}/) // 日期格式对
}同理,测跳转不能只断言 URL 变了——白屏页的 URL 也是对的:
ts
await expect(page).toHaveURL(/\/notes\//)
await expect(page.locator('.post-item').first()).toBeVisible() // 目标页真的有内容用等价类砍掉重复,但边界要单独测
站上有 6 篇文章,不需要给每篇都写一套测试。它们走的是同一条渲染路径,属于同一个等价类,测一篇代表就够了:
ts
test('正文渲染且有唯一标题', async ({ page }) => {
await page.goto('/notes/claude-code-quickstart') // 任选一篇代表
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Claude Code 快速入门')
})但列表要整体测——因为"漏掉某一篇"恰恰是等价类内部的差异:
ts
await expect(page.locator('.post-item')).toHaveCount(EXPECTED_POST_COUNT)真正值得单独写的是边界:
- frontmatter 缺
title时会渲染成空白条目 —— 已覆盖,就是上面那条not.toBeEmpty() draft: true的文章不该出现在列表和 RSS 里 —— 尚未覆盖- 0 篇文章时页面不该崩 —— 尚未覆盖
后两条我暂时没写,因为需要往仓库里塞一篇专门的草稿文章当夹具,收益还不值这个代价。但它们确实是缺口,写在这里免得自己忘掉。
测试套件永远有缺口,重要的是你知道缺口在哪。 一份"我知道这里没测"的清单,比一个 85% 的覆盖率数字有用得多。
每个真 bug 都该留下一条测试
回归测试最好的来源不是想象,是历史。这次三个 bug 全都留了对应用例:
| bug | 留下的测试 |
|---|---|
| 日期格式错 | toContainText(/\d{4}-\d{2}-\d{2}/) |
| 404 未生效 | getByText('页面走丢了') |
| 对比度不足 | axe 扫描(含暗色模式) |
还有一条来自更早的教训:索引页曾经漏列了一篇文章,所以有了这个显式断言:
ts
test('实战教程未被遗漏', async ({ page }) => {
await page.goto('/notes/')
await expect(page.getByRole('link', { name: /从零搭建个人博客/ })).toBeVisible()
})修 bug 时顺手补一条测试,成本最低——你正好在现场,知道它怎么坏的、怎么复现。
决定这条断言该放在哪一层
同一件事往往能在好几层验证,选错层会让套件又慢又脆。我的决策顺序:
能读文件验证的 → 读构建产物(artifacts.spec.ts) ~5ms
能发 HTTP 验证的 → request fixture(http.spec.ts) ~10-300ms
必须渲染才能验证 → 浏览器(其余 spec) ~1-2s具体例子:
- RSS 字段完整性 → 读
dist/feed.rss文本。不需要浏览器,也不需要服务器。 - 旧
.html地址不 404 →request.get()看状态码。不需要渲染。 - 暗色模式品牌色 → 必须浏览器,因为要读
getComputedStyle。
本站 HTTP 层那组 6 个用例跑完 3.4 秒,浏览器那组 70 多个用例要 45 秒。凡是不需要渲染的断言就往下沉,这是控制套件时长最有效的手段。
明确什么不测
用例设计的另一半是划边界。我明确不测:
框架自身的行为。VitePress 的路由、搜索索引、markdown 渲染是上游的责任,测它等于替别人写测试,而且会在每次升级时炸给你看。
上游的样式问题。axe 报了 shiki 代码注释色(4.45,差 0.05)和 VitePress 侧边栏结构。这些不是我的代码——但也不能假装没有,正确做法是显式排除并写明原因和复查时机:
tsreturn new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .exclude('pre') // shiki 主题的注释色,见文件头注释 .disableRules(['nested-interactive']) // VitePress 侧边栏组件结构 .analyze()实现细节。class 名、DOM 层级、内部状态。测这些等于把重构成本翻倍。
一条实用的判据:如果我重写了实现但用户行为完全没变,这条测试应该照样通过。 通不过,说明它测的是实现而不是行为。
三、按钮和交互怎么测
先说最影响长期成本的一件事:选择器怎么选。
优先级从高到低:
ts
page.getByRole('link', { name: '开始阅读' }) // 最优
page.getByLabel('搜索') // 表单元素
page.getByText('最新文章') // 文本内容
page.getByTestId('post-item') // 前面都不适用时
page.locator('.post-item > a:nth-child(2)') // 最差getByRole 排第一是因为它同时验证了功能和无障碍:测试能靠 role 找到按钮,说明屏幕阅读器也能。而 CSS 选择器会在你改个 class 名时全线崩溃——这是 E2E 套件被团队放弃的头号原因。
很多教程满屏 data-testid,那套适合组件库 class 名不稳定的复杂应用。内容站点用语义化 HTML,getByRole 更合适,而且不用为了测试去改源码。
一个真实例子,测 404 页的返回按钮:
ts
test('404 页的返回链接可用', async ({ page }) => {
await page.goto('/nope')
await page.getByRole('link', { name: '去看笔记' }).click()
await expect(page).toHaveURL(/\/notes\//)
await expect(page.locator('.post-item').first()).toBeVisible()
})注意最后一行:不只断言 URL 变了,还断言目标页面真的渲染出了内容。只测 URL 的话,跳到一个白屏页也算通过。
显式 role 会覆盖隐式语义
我在搜索功能上栽了一跤。搜索结果是 <li>,我理所当然写了:
ts
page.getByRole('listitem') // 永远找不到实际 DOM 是 <li role="option">。显式的 role 属性会覆盖标签的隐式语义,li 不再是 listitem。正确写法:
ts
const results = page.getByRole('option')
await expect(results.first()).toBeVisible()
await expect(results.first()).toContainText('提示词工程')移动端是另一套交互
导航栏在小屏会折叠成汉堡菜单,桌面端的测试在移动端必然失败。不要用一个测试硬扛两种布局:
ts
test('导航栏可用(桌面)', async ({ page, isMobile }) => {
test.skip(!!isMobile, '移动端导航收在汉堡菜单里')
// ...
})
test('导航栏可用(移动端汉堡菜单)', async ({ page, isMobile }) => {
test.skip(!isMobile, '移动端专用')
await page.locator('.VPNavBarHamburger').click()
// ...
})四、组件和插件怎么测
「插件」在不同语境下差别很大。对 VitePress 这类框架,主题扩展就是插件——本站的 ReadingProgress.vue 和 NotFound.vue 都是注入到框架渲染流程里的组件。
这类测试的重点不是组件内部逻辑,而是它有没有被正确挂载进宿主。上面 bug 2 就是典型:组件本身完全正确,是注册方式错了。
ts
test('文章页存在进度条并随滚动增长', async ({ page }) => {
await page.goto('/notes/prompt-engineering-guide')
const bar = page.locator('.reading-progress')
await expect(bar).toBeAttached()
const before = await bar.evaluate((el) => (el as HTMLElement).style.width)
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2))
await expect
.poll(async () =>
bar.evaluate((el) => parseFloat((el as HTMLElement).style.width) || 0)
)
.toBeGreaterThan(parseFloat(before) || 0)
})两个细节:
toBeAttached()而不是toBeVisible():进度条初始宽度是 0%,在 Playwright 眼里不算「可见」。选错断言会得到一个永远失败的测试。expect.poll():滚动后的宽度更新是异步的,poll会反复求值直到满足条件或超时,比waitForTimeout精确得多。
测「降级行为」时,要先测降级本身生效了
本站的粒子动画和打字机会在系统开启「减少动效」时不启动。我最初这样写:
ts
test.describe('prefers-reduced-motion', () => {
test.use({ reducedMotion: 'reduce' })
test('不启动粒子', async ({ page }) => {
await page.goto('/')
await expect(page.locator('canvas')).toHaveCount(0) // ✅ 通过了
})
})测试通过了。但它什么都没验证。
我顺手打印了一下 matchMedia:
matchMedia: false
canvas count: 1test.use({ reducedMotion }) 在嵌套的 describe 里没有传递到浏览器 context,模拟根本没生效。canvas 明明存在,测试却"通过"了——因为在没有模拟的情况下,我的选择器碰巧也匹配不到(当时选择器也有问题)。
这是最危险的一类失败:假绿。它比失败更糟,因为你以为有保护,其实没有。
改用 emulateMedia,并且额外加一条测试来守住前提:
ts
test('prefers-reduced-motion 下不启动粒子与打字机', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
await expect(page.locator('#particles-container canvas')).toHaveCount(0)
await expect(page.locator('.typewriter-text')).toBeEmpty()
})
test('模拟本身生效(守住上面那条测试的前提)', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
const matched = await page.evaluate(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
expect(matched).toBe(true)
})凡是依赖环境模拟的测试,都该配一条断言模拟生效的守卫测试。 否则你无法区分「行为正确」和「什么都没发生」。
五、接口怎么测
静态博客没有业务 API,但它有别的契约:页面路由、静态资源、RSS。测这些不需要启动浏览器,用 request fixture 直接发 HTTP:
ts
test('首页返回 200 与 HTML', async ({ request }) => {
const res = await request.get('/')
expect(res.status()).toBe(200)
expect(res.headers()['content-type']).toContain('text/html')
})
test('带 .html 的旧地址仍可访问(不制造死链)', async ({ request }) => {
const res = await request.get('/notes/claude-code-quickstart.html')
expect(res.status()).toBe(200)
})速度差距非常明显:本站这组 HTTP 测试整组跑完 3.4 秒,单条 10~343 毫秒;同等数量的浏览器测试要慢一个数量级。凡是不需要渲染的断言,都该下沉到这一层。
RSS 是真正值得测的接口
RSS 的消费者是各类阅读器。字段缺失时不会报错,只会在订阅端静默显示成空白行或「Invalid Date」——典型的无声失败,最适合自动化。
ts
for (const item of items) {
expect(field(item, 'title')).toBeTruthy()
expect(field(item, 'link')).toMatch(/^https:\/\//)
expect(field(item, 'guid')).toBeTruthy()
// pubDate 必须是 RFC 822,ISO 格式会被部分阅读器判为无效日期
const pubDate = field(item, 'pubDate')
expect(new Date(pubDate!).toString()).not.toBe('Invalid Date')
}写这段时我又踩了一次自己的坑:正则写成 <guid> 匹配不到 <guid isPermaLink="false">,测试报错。这次是测试错了,RSS 是对的——每次失败都要先分清是哪一边的问题,不要条件反射去改产品代码。
如果你测的是真实业务 API,同样的思路:优先测契约(字段、类型、状态码、错误结构),而不是实现细节。
六、覆盖率:一个容易被照抄教程带偏的话题
先给结论:对 E2E 测试来说,覆盖率百分比几乎不值得追。
这不是偷懒的说法。我实际接了一次 Playwright 的 V8 覆盖率,跑首页,输出是这样的:
=== 采集到的脚本数: 195
index.js 35470B 已执行≈120%
index.ts 2695B 已执行≈118%
client 137802B 已执行≈107%
head.js 19354B 已执行≈129%两个问题一眼可见:
1. 百分比超过了 100%。 因为 V8 的原始输出是函数级和块级的字节偏移范围,而且互相嵌套——一个函数的 range 里还套着 if 分支的 range。把它们的长度直接相加,重叠部分被重复计算,自然算出 120%。想得到正确数字必须用 v8-to-istanbul 之类的工具做区间合并。照着教程抄一段"计算覆盖率"的代码,很容易得到一个看起来煞有介事、其实毫无意义的数字。
2. 195 个脚本里,绝大多数不是我的代码。 Vue、VitePress 运行时、各种内部模块全在里面。我自己写的 posts.data.mts、ReadingProgress.vue 加起来不到全部字节数的 1%。这个比例下的"总覆盖率",反映的是框架代码被执行了多少,跟我的代码质量没什么关系。
还有个工程限制:V8 覆盖率只在 Chromium 可用,WebKit 和 Firefox 不暴露对应接口。跨浏览器套件里,覆盖率天然只能采集一部分。
那该看什么
Google 早在 2015 年那篇 Just Say No to More End-to-End Tests 里就指出,E2E 测试运行慢、定位难、容易不稳定,不该承担主要的覆盖职责。行业里更实用的共识是:40% 覆盖了正确流程,胜过 80% 覆盖了无关紧要的流程。
与其问"覆盖率多少",不如问:
如果我现在部署,某个关键用户流程坏掉而我不知道的概率有多大?
对本站,我把它拆成一份清单——每一条都对应一个真实测试:
| 关键流程 | 坏掉的后果 |
|---|---|
| 首页/索引页列出全部文章 | 新文章发了没人看得到 |
| 文章页正文渲染 | 内容站的核心功能没了 |
| 搜索能找到并跳转 | 老文章被埋 |
cleanUrls 与旧 .html 地址都可达 | 存量链接全部 404 |
| RSS 字段完整、时间倒序 | 订阅端静默显示异常 |
| 暗色模式与对比度达标 | 部分用户读不清 |
| 减少动效偏好被尊重 | 前庭敏感用户不适 |
这份清单是照着"坏了会怎样"写的,不是照着代码文件写的。它有 7 项,覆盖率百分比是多少我不知道,也不需要知道。
如果你的项目确实需要覆盖率数字(比如团队有硬性门禁),更合理的做法是:在单元测试和集成测试上统计覆盖率,E2E 只统计关键流程清单的完成度。 这也是测试金字塔的本意——它是个反馈速度模型,不是覆盖率模型。
七、几个反复出现的坑
取值方法不会自动等待
这个坑我在同一个项目里踩了两次:
ts
// ❌ 首页刚 goto,列表还没水合,拿到空数组
const titles = await page.locator('.post-item a').allTextContents()
// ✅ 先等到位再取值
await expect(page.locator('.post-item')).toHaveCount(5)
const titles = await page.locator('.post-item a').allTextContents()Playwright 的自动等待只作用于 locator 的操作和 expect(locator) 断言。allTextContents()、evaluateAll()、textContent()、count() 这些是调用瞬间的快照,不会重试。
危险之处在于它有时候能通过——本地快就通过,CI 慢就失败,于是变成经典的 flaky 测试。
不要用 waitForTimeout
ts
await page.waitForTimeout(3000) // ❌ 又慢又仍然会 flaky
await page.locator('.post-item').click() // ✅ 自动等待可交互固定等待要么太短(照样失败)要么太长(拖慢整个套件)。需要等特定条件时,用 expect.poll() 或 waitForResponse()。
明确区分「产品坏了」和「测试写错了」
这次 10 个失败里 7 个是测试自己的问题:选择器错、断言过时、API 用法不对、没考虑响应式布局。看到红色的第一反应不该是改产品代码。
一个实用习惯:失败时先把实际 DOM 打出来看看。
ts
console.log(await page.locator('.VPLocalSearchBox .results').innerHTML())我就是靠这一行发现搜索结果是 <li role="option"> 的。
用 --repeat-each 验证稳定性
bash
npx playwright test --repeat-each=3全绿不代表稳定。本站这套跑 3 遍共 108 次全通过,才敢说没有 flaky。新写的测试至少重复跑一次,尤其是涉及动画、异步加载的。
最后:这套测试的实际形态
tests/e2e/
├── content.spec.ts 文章列表、正文渲染、侧边栏
├── navigation.spec.ts 路由、404、搜索、移动端导航
├── appearance.spec.ts 品牌色、暗色模式、动效降级、响应式
├── a11y.spec.ts axe 扫描(含暗色)、键盘可达
├── http.spec.ts HTTP 契约、RSS 字段
└── artifacts.spec.ts 构建产物:RSS、sitemap、og meta70 个用例,桌面 + 移动两套设备,跑完约 45 秒。
它没有覆盖率数字,但它在我改任何东西的时候,能在 45 秒内告诉我有没有弄坏那 7 条关键流程。对一个个人博客来说,这比任何百分比都实用。
参考
- Coverage | Playwright 官方文档 —— V8 覆盖率 API 与限制
- Just Say No to More End-to-End Tests | Google Testing Blog —— E2E 测试为何不该承担主要覆盖职责
- playwright-test-coverage —— 用 Istanbul 采集覆盖率的完整示例
- How To Measure Code Coverage in Playwright Tests —— v8-to-istanbul 转换流程