让 agent 操作网页,最难写测试的不是点击,而是它依赖的外部世界:登录 cookie 会过期、搜索接口每次返回不同结果、第三方脚本时有时无。你没法要求 CI 每次都连真实站点,也不该把账号密码塞进流水线。Playwright 的 HAR 录制与回放正好解决这一段:先把一次成功会话完整录下来,再让测试从本地文件回放。
第一步:录一次「成功路径」
HAR 是浏览器网络请求的归档格式。Playwright 可以在创建 context 时开启录制,把页面发出的请求和响应写进一个 .har 文件。录制时用测试账号,走一遍你要固化的路径:打开首页、输入关键词、翻到第二页、打开详情、提交表单。
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context = await browser.newContext({
recordHar: { path: 'fixtures/search-flow.har', content: 'embed' },
baseURL: 'http://localhost:3000',
});
const page = await context.newPage();
await page.goto('/');
await page.getByPlaceholder('搜索').fill('agent');
await page.getByRole('button', { name: '搜索' }).click();
await page.getByRole('link', { name: /第二页/ }).click();
await page.locator('.result-item').first().click();
await context.close();
await browser.close();content: 'embed' 会把响应体直接写进 HAR,回放时不需要再访问网络。文件会比较大,适合放在测试仓库里,但不要提交真实用户数据。
第二步:录制后立刻脱敏
HAR 里会有 cookie、Authorization 头、手机号和邮箱。录制完成后先跑一遍清洗脚本,把敏感字段替换成固定值,再提交。不要等到测试失败才想起这件事。
const fs = require('fs');
const har = JSON.parse(fs.readFileSync('fixtures/search-flow.har', 'utf8'));
const SECRET = /^(cookie|authorization|set-cookie|x-api-key)$/i;
for (const entry of har.log.entries) {
for (const h of entry.request.headers) {
if (SECRET.test(h.name)) h.value = 'REDACTED';
}
entry.request.url = entry.request.url.replace(/token=[^&]+/g, 'token=REDACTED');
}
fs.writeFileSync('fixtures/search-flow.har', JSON.stringify(har, null, 2));第三步:让测试从 HAR 回放
回放用 page.routeFromHAR。它会按 URL 和 method 匹配录制里的响应;匹配不到时默认直接 abort,这样你能立刻发现「有请求没录到」,而不是偷偷打到真实网络。
import { test, expect } from '@playwright/test';
test('搜索到详情的完整路径', async ({ page }) => {
await page.routeFromHAR('fixtures/search-flow.har', {
url: '**/api/**',
notFound: 'abort',
});
await page.goto('/');
await page.getByPlaceholder('搜索').fill('agent');
await page.getByRole('button', { name: '搜索' }).click();
await expect(page.locator('.result-item')).toHaveCount(10);
await page.getByRole('link', { name: /第二页/ }).click();
await expect(page.locator('.result-item').first()).toContainText('Agent');
});如果页面在浏览器里直接请求第三方脚本,把 url 收窄到自己的 API 域名,第三方脚本用 page.route 单独 stub。不要为了省事把整页都回放,否则前端代码改了、样式崩了,测试仍然会绿。
第四步:处理动态数据
录制里的时间、价格、ID 会过期。两类处理方式:一类是把断言改成结构断言(有 10 条结果、每条有标题和链接),不要断言具体文案;另一类是在回放前用 route 改写响应,把动态字段替换成固定值。前者更稳,后者更适合必须校验具体内容的场景。
await page.route('**/api/search**', async (route) => {
const response = await route.fetch();
const body = await response.json();
body.items = body.items.map((item: any, i: number) => ({
...item,
id: `fixture-${i}`,
publishedAt: '2026-01-01',
}));
await route.fulfill({ response, json: body });
});第五步:把失败原因留在报告里
HAR 回放失败最常见的原因是「请求没录到」:前端新增了一个接口、URL 参数顺序变了、或者录制时走的是缓存。开启 trace,失败时把未匹配的 URL 打出来,比只看一句 timeout 有用。
test.use({ trace: 'retain-on-failure' });
page.on('requestfailed', (req) => {
console.log('FAILED', req.method(), req.url(), req.failure()?.errorText);
});第六步:更新基线要有评审
录制文件是测试资产,不是随手可改的缓存。接口真的变了,就重新录一次,并在 PR 里说明「哪个接口、什么字段变了、断言为什么跟着改」。否则 agent 一失败就重录,测试会慢慢失去意义。
- HAR 文件按流程命名(search-flow、checkout-flow),不要一个文件装全站。
- 录制用固定测试账号与固定数据种子,减少无关差异。
- CI 里设置 notFound: 'abort',禁止回放测试偷偷访问真实网络。
- 每月检查一次 HAR 体积,超过几 MB 就拆分或只保留关键接口。
最后:这套方法适合什么
它适合「流程固定、外部依赖多」的 agent 任务:搜索、登录、下单前几步、后台审核。它不适合验证真实第三方服务是否可用——那属于冒烟测试,应该单独跑、单独告警。把两者分开,回放测试才能稳定,冒烟测试才能真实。