用 Playwright 给个人项目补上端到端测试

个人项目没有测试团队,但 Playwright 让“一次配置、长期兜底”变成现实。记录用一天时间为博客搭起端到端测试的全过程。
去年我把个人博客重写成 FastAPI 加 Vue3 之后,最怕的一件事不是写不出新功能,而是改了一个搜索分页的 bug,顺手把发布接口的逻辑带崩了,自己还毫无察觉。个人项目没有测试团队兜底,CI 也是后来才配的,所以很长一段时间里,我的"回归测试"就是手动点一遍:登录、发一篇、搜一下、翻两页。这种方式在功能少的时候还撑得住,项目一长大就彻底崩了,我根本记不住所有该点的地方。
于是我给自己补了一层端到端测试,工具选了 Playwright。这篇文章不谈它和 Cypress、Selenium 的优劣之争(那个话题能吵三天),只聊一个务实的问题:作为一个精力有限、只想保住关键路径的个人开发者,怎么用最小的代价,把 E2E 真正跑起来、跑进 CI、并且不沦为"越写越乱"的负担。
下面这张图是我后来搭的测试分层思路:E2E 只罩住最粗的那几根水管,单元测试与组件测试留给更细的节点。
个人项目要不要上 E2E
先说结论:要,但只保关键路径。E2E 是最贵的一类测试,它要起浏览器、起服务、造数据、跑完整链路,速度慢、易碎、维护成本随用例数线性上升。如果你试图给个人项目写覆盖所有分支的 E2E,三个月后你会恨它,然后全删掉。
所以我给自己定的范围是四条主干:登录、发布文章、搜索、分页。这四条一旦坏了,博客就处于"用户能感知到不可用"的状态,值得用 E2E 守住。至于"编辑草稿时标题字数统计是否正确"这种细节,交给组件测试或简单的单测就行。
一个判断标准:如果某个功能挂了,你会从用户反馈或自己使用里立刻发现,那它大概率值得 E2E;如果只是后端某个内部字段算错、前端还有兜底展示,那不必用最贵的测试去盯它。
Playwright 上手:装好、录好、调好
安装很轻量。在已有项目里:
npm init -y
npm i -D @playwright/test
npx playwright installplaywright install 会拉取浏览器二进制,注意它默认装在用户目录,CI 里要显式执行这一步,否则跑测试时找不到浏览器。
最让我惊喜的是 codegen,它能边录边生成代码。你打开一个浏览器,手动点登录、填表单,它就把对应的操作翻译成 Playwright 脚本:
npx playwright codegen http://localhost:5173录出来的代码长这样(我整理过命名):
import { test, expect } from '@playwright/test';
test('登录并发布', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('me@example.com');
await page.getByLabel('密码').fill('secret');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});但我要提醒一句:codegen 是入门拐杖,不是终点。直接拿它生成的脚本当正式用例,你会发现满屏都是 page.click('#btn-3') 这种脆弱的定位。真正该做的是把它当"灵感来源",然后改写成基于角色和可读文本的定位,后面会展开。
调试这块 Playwright 的 trace viewer 是真香。测试失败时用:
npx playwright test --trace on
npx playwright show-trace它会打开一个时间轴页面,每个步骤的 DOM 快照、网络请求、控制台日志都能回放,比对着失败截图猜"刚才页面到底长啥样"高效十倍。我早期一半的 E2E bug 是靠 trace 里的"点击前那帧页面"看明白的,原来某个弹窗把按钮挡住了。
测行为,不要测像素
这是我在 E2E 上踩过最大的坑,也最想传给同样写个人项目的你:别用截图对比当断言。截图测试(snapshot testing 的像素版)极其脆弱,换台机器字体渲染差一像素就红,主题色微调一下全挂,维护成本爆炸。E2E 该断言的是"行为结果",而不是"长相"。
Playwright 的定位器(locator)体系天然鼓励这点。优先用 getByRole、getByLabel、getByText 这类"用户视角"的定位器,而不是 CSS 选择器或坐标:
// 好:从用户能感知的角色/文本出发
await page.getByRole('button', { name: '发布' }).click();
await expect(page.getByRole('heading', { name: '发布成功' })).toBeVisible();
// 差:耦合了实现细节,重构一次就碎
await page.click('#publish-btn-2');getByRole 底层依赖无障碍树(accessibility tree)。这带来一个意外好处:当你被迫用"按钮叫什么名字""标题是什么级别"去写测试时,你其实在倒逼自己写出语义化、可访问性更好的页面。我修了好几个 div 伪装成按钮的坏味道,纯粹是因为它们没法被 getByRole 干净地定位。
更进一步,Playwright 的 toHaveAccessibleName、toHaveRole 这类断言,让你能把"可访问性"也变成可测的属性,而不只是嘴上说说。
可达性快照(accessibility snapshot)的价值在于:它剥掉了样式,只留下结构。当你断言"发布后页面出现一个叫'发布成功'的标题",你测的是用户真正在乎的结果,而不是像素排列。重构样式时这类测试稳如老狗。
数据准备与环境隔离
E2E 最容易被坑的是"测试互相污染"。测试 A 发了一篇文章,测试 B 假设列表是空的,结果随机失败,这种 flaky 测试比没有测试还伤士气。
我的做法是:每个测试在 beforeEach 里重建一套独立状态。个人项目没有复杂的多租户,我直接用测试专用账号 + 每次清空该账号数据:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ request }) => {
// 调用一个仅测试环境暴露的清理接口
await request.post('/api/test/reset', {
data: { scope: 'e2e-user' },
});
});
test('搜索能命中刚发布的文章', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('e2e@example.com');
await page.getByLabel('密码').fill('e2e-pass');
await page.getByRole('button', { name: '登录' }).click();
await page.goto('/compose');
await page.getByLabel('标题').fill('Playwright 实战笔记');
await page.getByLabel('正文').fill('这是一篇用于搜索测试的文章');
await page.getByRole('button', { name: '发布' }).click();
await page.goto('/search?q=Playwright');
await expect(page.getByRole('link', { name: 'Playwright 实战笔记' })).toBeVisible();
});request 是 Playwright 自带的 API 客户端,我用它做登录态或数据铺设,比在浏览器里一步步点要快且稳。关键是"每个测试都从干净状态开始",这样它们才能并行、随机顺序跑而不互相影响。
另一点经验:别在 E2E 里依赖生产数据库。我用独立的 test 配置指向一个临时库(或用 SQLite 内存库),CI 里每次新建。这样本地和云端跑的是同一套隔离逻辑。
跑进 CI:无头、截图、产物
本地能跑只是第一步,真正的价值是每次 push 都自动跑。我的 CI(以 GitHub Actions 为例)关键步骤:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
- name: 上传失败产物
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/几个要点:--with-deps 在 Ubuntu 上装系统级依赖,否则 headless Chromium 起不来;if: ${{ failure() }} 保证只有失败时上传报告,省存储;playwright-report/ 是 playwright test --reporter=html 生成的,里面带 trace 和失败截图,下载下来本地 npx playwright show-report 就能复盘。
CI 里默认就是无头(headless)模式,不需要你额外加参数。但如果你在本地开发时想看浏览器真的在动,用 npx playwright test --headed 即可。
失败截图和 trace 一定要作为产物保留。我有过一次只在 CI 失败的案例,本地怎么跑都绿,最后靠 CI 留下的 trace 发现是 CI 环境下时区不同导致日期排序出错。没有产物,这种问题只能靠玄学。
测试代码本身也要重构
这是本文最后、也最容易被忽略的一点。E2E 用例写多了,你会看到大量重复:登录那五步、打开发布页那三步,在每个测试里复制粘贴。一旦登录流程改了,你要改二十个地方,这正是测试变负债的起点。
解决方式和业务代码一样:抽公共函数、用 fixture、封装页面对象。
// fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('e2e@example.com');
await page.getByLabel('密码').fill('e2e-pass');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await use(page);
},
});// 业务测试里直接复用
import { test, expect } from './fixtures';
test('已登录用户能发布', async ({ loggedInPage }) => {
await loggedInPage.goto('/compose');
await loggedInPage.getByLabel('标题').fill('重构后的测试');
await loggedInPage.getByRole('button', { name: '发布' }).click();
await expect(loggedInPage.getByText('发布成功')).toBeVisible();
});fixture 把"已登录页面"做成一个可注入的能力,测试只关心自己的业务步骤。登录逻辑改了,只动 fixtures.ts 一处。再往上还能抽 Page Object,把"发布一篇文章"封装成 composePage.publish(title, body)。
测试该守在哪一层,以及我踩过的坑
前面把 E2E 的范围圈在"关键路径",这背后是一个分层观念:不是所有验证都该用 E2E 做。我给自己划了三条线,纯函数逻辑(比如标题长度校验、Markdown 转 HTML)交给单元测试,快且便宜;组件交互(比如编辑器工具栏的启用禁用、表单校验提示)交给组件测试(Vitest + Testing Library),不启动真实浏览器;只有"跨多个页面、涉及真实后端"的链路才用 Playwright。越往上的层越贵,所以让它只罩最粗的水管,这才经济。
这里有几个我实打实踩过的坑,写出来省你时间。
第一个坑是 baseURL 没分离环境。早期我把测试直接写死 localhost:5173,结果 CI 里前端跑在 4173 预览端口,整套全红。正确做法是在 playwright.config.ts 里用环境变量注入 baseURL,本地和 CI 各配各的,测试代码里只写相对路径 /login。
第二个坑是误以为"登录 fixture 复用"会泄漏状态。我用 beforeEach 每次重置数据,但有一次偷懒在 beforeAll 里只登录一次、让所有测试共享同一个已登录页面,结果测试 B 依赖测试 A 发的文章,顺序一变就挂。结论是:能独立就独立,共享状态是 flaky 的温床,除非你明确为性能牺牲一点隔离(并且接受它带来的顺序依赖)。
第三个坑是下载类功能没测。博客有"导出 Markdown"按钮,原本我以为点一下就行,直到某次重构把响应头写错,下载下来的文件是空的,E2E 却一路绿,因为我只断言了"按钮可点",没断言"下载到了正确的内容"。后来补成监听 page.waitForEvent('download') 并校验文件名与大小,才真正兜住。
第四个坑是 CI 里没缓存浏览器。每次 playwright install 拉几百 MB,流水线从 3 分钟拖到 8 分钟。给 GitHub Actions 加一步缓存 ~/.cache/ms-playwright 后,时间又回来了。这种工程细节不起眼,却是你愿不愿意常跑 E2E 的关键,跑得慢,你就懒得跑,懒得跑,它就成了摆设。
说到底,E2E 能不能在你项目里活下来,不取决于你写了多少用例,而取决于它够不够快、够不够稳、够不够省心。把层级分清楚、把坑提前踩平,它才肯长期替你值班。
自动等待的纪律与 flaky 治理
E2E 最让人崩溃的不是写不出来,而是"时灵时不灵"。绝大多数 flaky 测试的根因,都出在等待上。新手最容易写的就是 page.waitForTimeout(2000),硬等两秒,赌那时候元素出现了。这招在本地十次有九次能过,一进 CI(CPU 被别的任务抢、磁盘 IO 慢)就随机红,而且红得毫无规律,极难排查。
Playwright 的核心优势之一是"自动等待":它的 click、fill 这类动作在底层会轮询元素是否可见、可操作,直到超时;而 expect 类是"web-first 断言",同样会自动重试一段时间。所以你几乎从不需要手写 sleep。正确姿势是直接对"结果状态"断言,把等待交给框架:
// 好:框架自动等到按钮出现且可点击
await page.getByRole('button', { name: '发布' }).click();
// 好:自动重试直到标题可见或超时
await expect(page.getByText('发布成功')).toBeVisible();
// 差:用固定 sleep 赌时序,CI 里必翻车
await page.waitForTimeout(2000);如果你确实要等某个后端异步完成(比如文件上传后的进度),也不要 sleep,而是断言它造成的可见结果,进度条消失、成功文案出现。让"状态"而非"时间"当你的信号。
万一还是有偶发失败,Playwright 支持配置重试,给脆弱用例一次自我补救的机会:
// playwright.config.ts
export default {
retries: process.env.CI ? 2 : 0, // CI 里失败重试两次,本地不重试以保持快
workers: process.env.CI ? 4 : undefined, // CI 并行度拉高
use: { trace: 'on-first-retry' }, // 只在首次重试时留 trace,省空间
};retries 不是让你纵容坏测试,而是给"环境抖动"留缓冲;真正反复红的用例必须修掉,不能靠重试掩盖。另外 workers 把用例并行跑,能大幅缩短总时长,但前提是你的测试做到了前面说的"独立状态":否则并行会互相踩数据,反而制造更多 flaky。
我还常用 route() 做网络层模拟,把不可控的外部依赖(如第三方搜索 API) stub 掉,让测试只验证我自己的逻辑:
test('搜索接口异常时页面有兜底', async ({ page }) => {
await page.route('**/api/search', route => route.abort());
await page.goto('/search?q=hi');
await expect(page.getByText('搜索暂时不可用')).toBeVisible();
});这种"故意让依赖失败"的测试,往往比正常路径更能暴露你真实的错误处理。最后提一句设备模拟:Playwright 的 projects 可以声明式地套用移动端视口与 UA,让你用同一套用例顺手验证响应式布局下的关键路径,个人项目不必专门再写一套移动端测试。
治理 flaky 的三句话:用状态断言代替 sleep,用独立状态支持并行,用重试兜底环境抖动而非掩盖逻辑 bug。守住这三条,E2E 才能长期健康地活着。
给一组可参考的完整用例
下面这组测试覆盖了我博客的四条关键路径,风格就是你上面看到的:基于角色定位、独立状态、结果断言、公共 fixture。
import { test, expect } from './fixtures';
test('登录失败有提示', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('e2e@example.com');
await page.getByLabel('密码').fill('wrong');
await page.getByRole('button', { name: '登录' }).click();
await expect(page.getByText('邮箱或密码错误')).toBeVisible();
});
test('发布后能在列表看到', async ({ loggedInPage }) => {
await loggedInPage.goto('/compose');
await loggedInPage.getByLabel('标题').fill('端到端测试示例');
await loggedInPage.getByLabel('正文').fill('正文内容');
await loggedInPage.getByRole('button', { name: '发布' }).click();
await loggedInPage.goto('/');
await expect(loggedInPage.getByRole('link', { name: '端到端测试示例' })).toBeVisible();
});
test('搜索能分页且结果正确', async ({ loggedInPage }) => {
await loggedInPage.goto('/search?q=测试');
await expect(loggedInPage.getByRole('listitem').first()).toBeVisible();
// 翻到第二页,断言 URL 带了 page 参数且内容变化
await loggedInPage.getByRole('button', { name: '下一页' }).click();
await expect(loggedInPage).toHaveURL(/[?&]page=2/);
});写完这一套,我最大的感受是:E2E 不是银弹,它是"关键路径的保险丝"。当我改完一个涉及发布和搜索的重构,跑一遍 npx playwright test 全绿,那种踏实感是手动点十遍都比不了的。对个人项目来说,这层保险值得花一天搭起来,然后让它躺在 CI 里默默替你值班。
给新手的几条最小行动建议
如果你看到这儿还没动手,我替你把门槛压到最低:今天只做三件事就够了。第一,装好 Playwright 并 codegen 录一遍登录流程,确认本地能跑通一个用例。第二,把那个用例改造成基于 getByRole 的定位,并抽成一个 loggedInPage 的 fixture。第三,在 CI 里加一个最小 job,安装浏览器后跑这一个用例、失败时上传报告。做到这三步,你已经比绝大多数个人项目多了一种"改坏了自己会知道"的能力。
剩下的,搜索、分页、上传、移动端、并行、重试,都是在这块地基上慢慢长出来的,不必一天堆完。测试体系最怕的是完美主义导致的迟迟不动,先有一个能跑、能报警的用例,比拥有一百个脆弱用例有用得多。
别追求覆盖率数字,追求"最坏情况下用户感知到的不可用,我能在 push 的十分钟内知道"。做到这一条,E2E 对你而言就算真正上线了。