Next.js
Next.js 这样的 SSR 框架会发起不带浏览器上下文的服务端 fetch 调用,它们经过代理。代理通过 x-test-rcrd-id header 识别这些请求属于哪个会话。Playwright 的 playwrightProxy.before() 已经在触发 SSR 的浏览器导航上设置了它,所以 id 在 next/headers 里可取 —— 我们要做的是把它附加到外发的服务端请求上。(纯浏览器测试不需要这些;代理会回退到全局设置的会话。)
registerProxyFetch(推荐)
Section titled “registerProxyFetch(推荐)”在你的 root layout 中加一行,就能给每一个服务端 fetch 打标 —— Server Components、Route Handlers,在 Node 和 Edge runtime 上都有效:
import { registerProxyFetch } from 'test-proxy-recorder/nextjs';
registerProxyFetch(); // production 下是 no-op,除非 TEST_PROXY_RECORDER_ENABLED=true它会 patch global fetch,把当前请求的 x-test-rcrd-id 复制到外发请求上,这样代理就能区分并发的回放会话。请从 root layout 调用 —— 不要放在 instrumentation.ts,它的上下文与 Edge runtime 上渲染你路由的上下文不同,放在那里的 patch 会悄悄地不触发。
axios —— registerProxyAxios
Section titled “axios —— registerProxyAxios”如果你的服务端请求走 axios,请对每个 server-side 实例注册一次:
import { registerProxyAxios } from 'test-proxy-recorder/nextjs';
registerProxyAxios(axiosForServer);它会添加一个 request interceptor,给请求盖上 id(绝不触碰 global fetch),所以不受上述 dev-server 警告影响。production / 浏览器下是 no-op;对每个实例幂等;绝不覆盖调用方已设置的 id。
按调用 —— createHeadersWithRecordingId
Section titled “按调用 —— createHeadersWithRecordingId”免 patch,在 next dev 下也可用。用于单个 fetch,或当你不想 patch global fetch 时:
import { headers } from 'next/headers';import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
const res = await fetch('http://localhost:8100/api/data', { headers: createHeadersWithRecordingId(await headers(), { 'Content-Type': 'application/json', }),});中间件(可选)
Section titled “中间件(可选)”调用 setNextProxyHeaders 的 proxy.ts(Next.js 16+,导出 proxy)或 middleware.ts(15 及更早,导出 middleware)能让 id 通过 next/headers 取到,但不给外发 fetch 打标 —— 所以当你使用上面任意一个 helper 时并不需要它。仅当你已经拥有一个中间件(鉴权等)时才考虑它,并且仍要搭配一个 helper 来做打标:
// proxy.ts (Next.js 16+)import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
export function proxy(request: NextRequest) { const response = NextResponse.next(); setNextProxyHeaders(request, response); // 暴露 id;请搭配上面的 helper return response;}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],};test-proxy-recorder/nextjs 辅助函数的完整签名请参见 API 参考。完整、可运行的 Edge 项目见 Edge runtime 示例。
缓存与 ISR
Section titled “缓存与 ISR”不要为了测试而关闭缓存 —— 录制器可以与缓存/ISR 路由共存。但有一条规则决定了整个设计:要回放一个 SSR fetch,页面必须在请求时真正执行该 fetch。 一个返回预渲染 HTML 或陈旧缓存渲染的路由根本不会发起 fetch,于是代理没有东西可返回,断言看到的是陈旧内容。
保持确定性的做法是:用 fetch 级别的 next.revalidate + next.tags 缓存这个 SSR fetch,然后在断言前按需失效:
// app/isr/page.tsx —— 不要 `export const dynamic`,不要 `export const revalidate`const res = await fetch(`${BACKEND_URL}/todos`, { next: { revalidate: 30, tags: ['isr-todos'] },});import { revalidateTag } from 'next/cache';revalidateTag('isr-todos', 'max'); // Next.js 16 需要第 2 个 profile 参数await page.request.post('/api/revalidate'); // 硬清除await page.goto('/isr'); // 一次导航 —— 确定性await expect(page.getByTestId('todo-text')).toHaveCount(1);对 fetch 缓存条目执行 revalidateTag 是一次硬清除:下一次读取会是缓存未命中,它会阻塞并通过代理重新发起 fetch。你必须在回放导航之前清除,因为数据缓存会跨同一个 next start 进程的录制 → 回放两个阶段存活 —— 否则回放会返回录制阶段的缓存而永远不会命中代理(假阳性)。
测试期间,被 patch 的 fetch 会读取 headers(),所以页面会动态渲染并真正执行 fetch。在生产环境(录制器关闭)下没有任何东西读取 headers(),页面照常是静态 ISR —— 动态渲染仅限于测试,并且是录制 SSR fetch 所固有的。
按需重新校验是特权操作(它清除缓存并强制重新生成),所以请用共享密钥保护该路由 —— 未设置时按失败关闭(fail closed)拒绝,用常量时间比较,并在测试中通过 Playwright 的 use.extraHTTPHeaders 附加 token,让 spec 本身从不接触密钥。
参见完整、可运行的示例(Next.js 16 示例的一部分):
app/isr/page.tsx—— 被缓存的页面(fetch 级别的next.tags)app/api/revalidate/route.ts—— 如何保护revalidateTag:失败关闭 + 常量时间密钥比较e2e/isr.spec.ts—— 先失效,再一次导航;断言重新校验调用成功playwright.config.ts—— 加载.env并通过extraHTTPHeaders附加密钥
package.json 脚本
Section titled “package.json 脚本”从脚本启动各服务,而不要从 playwright.config.ts 启动:
{ "scripts": { "mock": "node mock-backend/server.mjs", "proxy": "test-proxy-recorder http://localhost:3002 -p 8100 -d ./e2e/recordings", "start:all": "concurrently \"pnpm mock\" \"pnpm proxy\" \"pnpm build && next start --port 3000\"" }}完整、可运行的项目见 Next.js 16 示例。