E2E 测试里的网络层,到底该怎么 Mock?
上周 code review,一个同事的 E2E 测试挂了。原因挺离谱------后端灰度发布改了个字段名,userName 变成了 user_name,测试直接炸了。
他用的是 Playwright 的 route 拦截,mock 数据是手写的 JSON,跟真实接口早就对不上了。
这不是个例。E2E 测试的网络层处理,一直是个让人头疼的事。手写 mock 维护成本高,不 mock 又依赖后端环境,用 HAR 录制吧,录完的文件大得吓人。
三种主流方案摆在面前:Playwright 原生的请求拦截、HAR 录制回放、Mock Service Worker(MSW)。各有各的脾气,各有各的坑。
Playwright route:简单粗暴,但够用吗?
Playwright 自带的 page.route() 是最直接的方案。拦截请求,返回假数据,完事。
ts
// 拦截用户列表接口,直接返回写死的数据
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
],
}),
})
})
await page.goto('/dashboard')
// 页面拿到的永远是这俩人,不依赖后端
await expect(page.getByText('张三')).toBeVisible()
够直观。但问题也很直观------mock 数据写死在测试文件里,接口一改就得跟着改。
它还有个容易忽略的能力:断言请求本身。
ts
const requestPromise = page.waitForRequest('**/api/orders')
await page.getByRole('button', { name: '提交订单' }).click()
const request = await requestPromise
const body = request.postDataJSON()
// 验证前端提交的数据结构对不对
expect(body.items).toHaveLength(2)
expect(body.couponCode).toBe('SAVE20') // 优惠券有没有带上
这招在表单提交、搜索筛选这类场景特别好使。不光验证 UI 展示对不对,还能验证前端发出去的请求对不对。
适合的场景:测试用例少、接口稳定、mock 逻辑简单。十几个测试文件以内,手写 route 完全 hold 得住。
超过这个量级,你会发现自己在无数个测试文件里复制粘贴同一坨 mock 数据。
HAR 录制回放:听起来美好
Playwright 支持把真实的网络请求录下来,存成 HAR 文件,跑测试时回放。
ts
// 录制阶段:跑一遍真实流程,把所有请求响应存下来
test('录制用户流程', async ({ page }) => {
await page.routeFromHAR('tests/fixtures/user-flow.har', {
update: true, // update: true → 录制模式,跑真实请求并保存
})
await page.goto('/dashboard')
await page.getByRole('link', { name: '订单' }).click()
// ...正常操作,所有网络请求都会被记录到 har 文件
})
录完之后,把 update: true 去掉,测试就变成回放模式:
ts
test('回放用户流程', async ({ page }) => {
// 不带 update → 回放模式,请求直接从 har 文件里取响应
await page.routeFromHAR('tests/fixtures/user-flow.har')
await page.goto('/dashboard')
await expect(page.getByText('订单列表')).toBeVisible()
})
看起来很完美对吧?录一遍,后面就不用管了。
现实是:HAR 文件动不动几 MB。一个中等复杂度的页面,录下来的 HAR 里混着各种静态资源、埋点请求、第三方 SDK 的调用。你想 review 一下 mock 数据长啥样?祝你好运。
还有个更实际的问题------后端数据变了怎么办?重新录。录完发现某个接口需要登录态,之前的 cookie 过期了,再来一遍。CI 环境跟本地环境的请求顺序不一样,回放匹配不上,又挂了。
我之前在一个项目里试过全量 HAR 录制。三个月后,团队里没人敢动那些 .har 文件,因为不知道改了哪里会影响哪个测试。最后还是回到了手写 mock。
HAR 真正好用的场景:接口特别多、数据结构复杂、但接口本身很少变。比如对接一个稳定的第三方支付回调,录一遍省得手写那一大堆字段。
另外一个技巧是部分录制------只对特定接口用 HAR,其他的还是手写:
ts
// 只录制订单相关的接口,其他接口手动 mock
await page.routeFromHAR('tests/fixtures/orders.har', {
url: '**/api/orders/**', // 限定范围,别什么都录
})
// 用户信息还是手写,因为经常变
await page.route('**/api/user/profile', async (route) => {
await route.fulfill({
body: JSON.stringify({ name: '测试用户', role: 'admin' }),
})
})
这样 HAR 文件小、范围可控,出问题也好排查。
MSW:在 Service Worker 层拦截
Mock Service Worker 的思路不一样。它不在测试框架层拦截,而是在浏览器的 Service Worker 层拦截请求。
ts
// handlers.ts ------ 集中定义所有 mock 规则
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json({
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
],
})
}),
http.post('/api/orders', async ({ request }) => {
const body = await request.json()
// 模拟业务逻辑:库存不够就返回错误
if (body.quantity > 100) {
return HttpResponse.json(
{ error: '库存不足' },
{ status: 400 }
)
}
return HttpResponse.json({ orderId: 'ORD-001' })
}),
]
MSW 最大的卖点是跨环境复用。同一套 handlers,开发时用、单元测试用、E2E 测试也能用。
在 Playwright 里集成 MSW,通常的做法是在页面加载前注入 Service Worker:
ts
// playwright 里用 msw,需要在页面上下文中启动
test.beforeEach(async ({ page }) => {
// 确保 mockServiceWorker.js 已经放到 public 目录
await page.goto('/')
await page.evaluate(async () => {
const { worker } = await import('/src/mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' })
})
})
但说实话,MSW 在 E2E 场景下的集成体验并不丝滑。
几个实际问题:
Service Worker 注册是异步的,有时候页面请求已经发出去了,Worker 还没准备好。你得处理时序问题。
另外,MSW 拦截的是浏览器端发出的请求,如果你的应用有 SSR,服务端发出的请求它拦不到。这时候得同时用 setupServer(Node 端)和 setupWorker(浏览器端),配置翻倍。
还有一个坑------Playwright 的 page.route() 和 MSW 同时存在时,谁先谁后?Playwright 的拦截在网络层更靠前,会先于 Service Worker 生效。如果你同时用了两者,可能出现"我明明在 MSW 里改了 mock,怎么没生效"的情况。
三种方案怎么选?
不搞复杂的表格了,直接说结论。
小项目、接口少 → Playwright route 就够了。手写 mock,简单直接,不用引入额外依赖。把常用的 mock 提成工具函数,复用一下就行。
ts
// utils/mock-helpers.ts
export async function mockUserAPI(page: Page, userData?: Partial<User>) {
await page.route('**/api/user/profile', (route) =>
route.fulfill({
body: JSON.stringify({
id: 1,
name: '默认用户',
role: 'viewer',
...userData, // 允许每个测试覆盖部分字段
}),
})
)
}
// 测试里一行搞定
test('管理员看到删除按钮', async ({ page }) => {
await mockUserAPI(page, { role: 'admin' })
await page.goto('/settings')
await expect(page.getByRole('button', { name: '删除' })).toBeVisible()
})
接口多但稳定、数据结构复杂 → HAR 录制回放,配合 url 过滤只录关键接口。
前后端并行开发、mock 要跨单元测试和 E2E 复用 → MSW。前期投入大一些,但 mock 规则统一管理的好处会随项目规模放大。
还有一种我个人比较喜欢的组合:MSW 管常规 mock,Playwright route 管特殊场景。
ts
// MSW 兜底处理所有常规接口(在 beforeEach 里启动)
// 但某个测试要模拟网络超时?用 Playwright route 覆盖
test('接口超时展示兜底 UI', async ({ page }) => {
// Playwright route 优先级高于 MSW,这里直接覆盖
await page.route('**/api/dashboard', (route) => route.abort('timedout'))
await page.goto('/dashboard')
await expect(page.getByText('加载失败,请重试')).toBeVisible()
})
这样常规的 happy path 用 MSW 统一管理,异常场景用 Playwright 在测试级别单独处理。各干各的活,互不打架。
一个容易忽略的事:请求断言
不管用哪种方案 mock 响应,请求断言都值得单独拎出来说。
很多人写 E2E 测试只验证页面展示:点了按钮 → 出现了成功提示。但中间那一步------前端到底发了什么请求------没人管。
ts
test('筛选条件正确传递到接口', async ({ page }) => {
await page.route('**/api/products*', (route) => {
route.fulfill({ body: JSON.stringify({ products: [] }) })
})
await page.goto('/products')
await page.getByLabel('分类').selectOption('electronics')
await page.getByLabel('价格区间').fill('100-500')
const [request] = await Promise.all([
page.waitForRequest((req) =>
req.url().includes('/api/products') && req.method() === 'GET'
),
page.getByRole('button', { name: '搜索' }).click(),
])
const url = new URL(request.url())
expect(url.searchParams.get('category')).toBe('electronics')
expect(url.searchParams.get('priceMin')).toBe('100')
expect(url.searchParams.get('priceMax')).toBe('500')
// 前端有没有正确拼参数?这里一目了然
})
之前碰到一个 bug:页面上筛选条件选了,展示也对,但实际发出去的请求少带了一个参数。用户看到的是全量数据,以为筛选生效了,其实没有。如果当时测试里加了请求断言,早就能发现。
几个踩过的坑
1. mock 和真实请求混着来
有时候你 mock 了 A 接口,但 B 接口没 mock,B 依赖真实后端。结果 CI 环境连不上后端,B 接口超时,整个测试挂了。
要么全 mock,要么明确哪些接口走真实的、确保 CI 环境能访问。别搞半吊子。
2. HAR 里的时间戳
HAR 文件里录下来的响应可能带时间戳字段,比如 createdAt: "2025-01-15T10:30:00Z"。测试里如果断言"显示今天的订单",过两天就挂了。
3. MSW 的 onUnhandledRequest
默认行为是 warn------没被 mock 的请求会打 warning 但正常放行。建议 E2E 里设成 error,这样漏掉的接口直接报错,别让它悄悄走真实请求。
ts
await worker.start({
onUnhandledRequest: 'error', // 宁可报错,也别悄悄放行
})
聊到这
网络层 mock 这事没有银弹。三种方案本质上是在控制粒度 和维护成本之间做取舍。
Playwright route 控制粒度最细,但维护成本跟测试数量线性增长。HAR 录制最省事,但黑盒程度高,出问题不好排查。MSW 在复用性上赢了,但引入了 Service Worker 这层额外的复杂度。
我现在的做法是:新项目先用 Playwright route 把核心流程的 E2E 跑起来,等接口稳定了、测试多了,再考虑抽 MSW。HAR 只在对接第三方、字段特别多的时候用一用。
至于哪种方案"最好"------取决于你的团队愿意在测试基建上投入多少。能把 mock 维护住、CI 跑得稳,用哪个都行。