Skip to content

给博客加自动化测试:抓到 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 #E8835A2.68
按钮 hover#ffffff on #D4694A3.54
标签 chip#B0542A on #FCEEE84.46
行内 code#B0542A on #E7E9EC4.16

两个问题:

  1. 我写完"此色不可承载文字",转头就让 VitePress 拿它当了实心按钮的底色配白字。
  2. 我算的 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 就测进度条……这样能写出一大堆测试,但它们的分布反映的是代码的组织方式,不是风险的分布

换个方向问三个问题:

  1. 这里坏了,谁会疼
  2. 坏了之后,多久会被发现
  3. 发现的时候,损失多大

本站的清单就是这么来的:

关键流程谁会疼多久发现损失
首页/索引页列出全部文章所有读者可能很久新文章白写
文章正文渲染所有读者很快站点等于挂了
搜索能找到并跳转老读者很久存量内容被埋
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 地址不 404request.get() 看状态码。不需要渲染。
  • 暗色模式品牌色 → 必须浏览器,因为要读 getComputedStyle

本站 HTTP 层那组 6 个用例跑完 3.4 秒,浏览器那组 70 多个用例要 45 秒凡是不需要渲染的断言就往下沉,这是控制套件时长最有效的手段。

明确什么不测

用例设计的另一半是划边界。我明确不测:

  • 框架自身的行为。VitePress 的路由、搜索索引、markdown 渲染是上游的责任,测它等于替别人写测试,而且会在每次升级时炸给你看。

  • 上游的样式问题。axe 报了 shiki 代码注释色(4.45,差 0.05)和 VitePress 侧边栏结构。这些不是我的代码——但也不能假装没有,正确做法是显式排除并写明原因和复查时机

    ts
    return 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.vueNotFound.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: 1

test.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.mtsReadingProgress.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 meta

70 个用例,桌面 + 移动两套设备,跑完约 45 秒。

它没有覆盖率数字,但它在我改任何东西的时候,能在 45 秒内告诉我有没有弄坏那 7 条关键流程。对一个个人博客来说,这比任何百分比都实用。


参考

用代码记录成长 · RSS 订阅 · 标签