Next.js 客户端路由 E2E 测试利器:createRouterAct 与 LinkAccordion 测试模式详解
Next.js 客户端路由的 prefetch、导航与 segment cache(段缓存)行为涉及大量异步网络请求,传统的"轮询等待 + 硬编码请求数"写法极易产生 flaky(不稳定)测试。Next.js 官方为此沉淀了一套名为 Router Act 的端到端测试模式:通过 test/lib/router-act.ts 中的 createRouterAct 工具拦截并断言路由请求,配合 LinkAccordion 组件精确控制 prefetch 的触发时机。读完本文,你将掌握 act 的完整配置语法、App Shell 请求的特殊处理、LinkAccordion 与 Hub Page 的标准写法、Playwright 假时钟的接入方式,以及官方测试中规避 flakiness 的四大禁忌,能够独立编写确定性的客户端路由 E2E 测试。
该模式的知识源文件为仓库内的 .agents/skills/router-act/SKILL.md,本文以它为骨架,并结合仓库源码与真实测试用例展开。
一、何时使用 act,何时不必用
createRouterAct 让你以端到端的方式断言 prefetch 与导航的响应内容,而不需要与"具体请求数"或"协议细节"耦合——这正是仓库中绝大多数客户端路由相关测试都采用该模式的原因。
判断标准很简单:
- 不需要
act:如果你既不想控制网络响应的时机、也不想断言响应内容,只是"导航后等某块 UI 出现",那么 Playwright 常规助手即可:browser.elementById()、browser.elementByCss()、browser.waitForElementByCss()。 - 需要
act:当你要验证"某次 prefetch 是否发生 / 返回了什么 / 是否走了缓存(没有发生请求)"时。
二、四条核心原则
- 用
LinkAccordion控制 prefetch 的触发时机:永远不要让<Link>在act作用域之外暴露于视口。 - 数据应来自缓存时,优先
'no-requests':这是最强的断言——它直接证明缓存生效了。 - 避免 retry/轮询定时器:
act的存在意义就是取代retry()循环、setTimeout等待网络活动这类天生 flaky 的模式。如果你发现自己想轮询,多半是act用错了。 - 避免
block功能:它容易出假阴性(false negative)。优先使用includes断言和'no-requests'。
三、Act API:全部配置形态
act 是 createRouterAct(page, options?) 的返回值,签名为 act(scope, config?)。config 的所有合法形态在源码 test/lib/router-act.ts#L114-L119 中定义为联合类型 ActConfig:
type ActConfig =
| ExpectedResponseConfig // 单个期望响应
| Array<ExpectedResponseConfig> // 多个期望响应(按序检查)
| 'block' // 拦截全部响应(仅限嵌套 act)
| 'no-requests' // 期望零请求
| null // 至少一个请求,不断言内容
3.1 配置选项全览
// 断言"不发起任何路由请求"(数据由缓存提供)。
// 尽可能优先使用——它是最强的断言。
await act(async () => { ... }, 'no-requests')
// 期望至少一个响应体包含该子串
await act(async () => { ... }, { includes: 'Page content' })
// 期望多个响应(按顺序检查)
await act(async () => { ... }, [
{ includes: 'First response' },
{ includes: 'Second response' },
])
// 断言同一内容出现在两个独立响应中
await act(async () => { ... }, [
{ includes: 'Repeated content' },
{ includes: 'Repeated content' },
])
// 期望至少一个请求发生,但不断言内容
await act(async () => { ... })
每个期望项 ExpectedResponseConfig 的完整字段(见 test/lib/router-act.ts#L76-L80):
| 字段 | 类型 | 含义 |
|---|---|---|
includes |
string |
期望响应体中出现的子串 |
block |
boolean | 'reject' |
true:拦截该响应不发给浏览器,直到外层 act 结束(仅限嵌套 act);'reject':若出现含该子串的响应则测试失败(反向断言) |
kind |
'static' | 'runtime' |
限定只匹配某类 prefetch 协议的响应(见下文) |
3.2 includes 匹配规则
includes子串针对 HTTP 响应体做子串匹配,应使用在渲染输出中字面出现的文本(如'Dynamic content (stale time 60s)')。- 未匹配任何断言的多余响应会被静默忽略——你只需断言关心的响应即可。这让测试与路由实际发起的请求数解耦。
- 每个
includes期望恰好认领一个响应。若同一子串出现在 N 个独立响应中,必须提供 N 个{ includes: '...' }条目;否则源码会在 test/lib/router-act.ts#L670-L684 抛出 "The same expected substring was sent multiple times by the server" 错误,提示你换更精确的子串。
3.3 App Shell 请求默认被忽略
这是使用 App Router + Cache Components 时最关键的一条规则。当 App Shells 启用(开启 Cache Components 后的默认行为)时,一次 prefetch 被拆成两阶段:
- App Shell prefetch:与 param/searchParam 无关的路由"外壳"(layouts、loading 边界、静态 shell);
- 按链接/按页的数据 prefetch:真正的每链接数据请求。
App Shell 概念上属于路由本身而非 prefetch 数据,因此 act 对所有断言目的都忽略 App Shell 请求。其识别依据是请求头 next-router-prefetch: '3'(对应客户端 FetchStrategy.RuntimeShell),源码常量见 test/lib/router-act.ts#L16-L17:
const NEXT_ROUTER_PREFETCH_HEADER = 'next-router-prefetch'
const APP_SHELL_PREFETCH_VALUE = '3'
由此带来的实践结论:
Loading...回退内容即使同时出现在 App Shell prefetch 与 per-link prefetch 中,你也只需写一个{ includes: 'Loading...' }——App Shell 那份对匹配不可见。- 即使触发了 App Shell prefetch,
'no-requests'依然通过。 block: 'reject'不会命中只出现在 App Shell 中的内容。
但注意:App Shell 请求仍会被拦截、满足(fulfilled)并等待(保证 shell 被缓存、不留下 in-flight 请求),只是不参与 includes 匹配、no-requests、block: 'reject' 和"至少一个请求"检查。若 App Shell 响应返回 4xx/5xx,测试仍会失败——错误状态检查作用于所有请求(test/lib/router-act.ts#L536-L552)。
3.4 显式断言 App Shell:includeAppShellRequests
针对专门验证 App Shell 行为的测试,可在 act 实例级别选择加入:
const act = createRouterAct(page, { includeAppShellRequests: true })
开启后,App Shell 请求与其他路由请求一视同仁。官方建议:能用可观测结果表达时,优先通过结果断言(例如"即时导航在数据响应到达前就渲染出已缓存的 shell"),而非直接断言 prefetch 内容。官方给出的标准示例是 test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts。
createRouterAct 的完整选项(test/lib/router-act.ts#L121-L140):
export function createRouterAct(
page: Playwright.Page,
options?: {
// 允许返回的服务端状态码;不提供时,所有 400+ 错误码都会让测试失败
allowErrorStatusCodes?: number[]
// true 时 App Shell 请求参与一切断言逻辑(用于 App Shell 专项测试)
includeAppShellRequests?: boolean
}
)
3.5 act 内部执行流程
act 会拦截作用域内发起的所有路由请求——prefetch、导航和 Server Actions。判定一个请求是否为路由请求的依据是请求头(test/lib/router-act.ts#L318-L325):
const isRouterRequest =
headers['rsc'] !== undefined || // 匹配导航与 prefetch
headers['next-action'] !== undefined // 匹配 Server Actions
完整内部流程为六步:
- 安装 Playwright route handler(
page.route('**/*', ...))拦截路由请求; - 执行你提供的 scope 函数;
- 等待一次
requestIdleCallback(用于捕获 IntersectionObserver 触发的 prefetch); - 把缓冲的响应发给浏览器;
- 重复第 3~4 步,直到没有新请求;
- 根据 config 对响应做断言。
关键点:响应被缓冲,直到 scope 函数返回后才转发给浏览器。因此你不能在同一个 scope 内"导航到新页面并等待其渲染"——那会死锁。正确姿势是:scope 内只触发导航(点击链接),让 act 处理其余部分,并在 act 返回之后再读目标页内容:
await act(
async () => {
/* 展开手风琴、点击链接 */
},
{ includes: 'Page content' }
)
// 在 act 返回后读内容,而不是在 scope 内读
expect(await browser.elementById('my-content').text()).toBe('Page content')
从源码结构看,act 还有几项防御性机制值得了解:
- 硬导航保护:若作用域期间发生整页刷新/硬导航(
framedetached事件),act会立即抛出 "A hard navigation or refresh was triggered during theactscope. This is not supported."(test/lib/router-act.ts#L411-L425、#L792-L797),避免 Playwright 等待孤儿请求而永久挂起。 - 首个请求看门狗:scope 结束后
act会等待首个请求发起,500ms 超时;若页面另有 in-flight 的 App Shell 请求(例如首屏视口 prefetch),看门狗会续期等待而非误报超时(#L447-L472)。 kind分类:源码根据请求头把响应分类——携带next-router-segment-prefetch头的 per-segment 静态 prefetch 归为'static',next-router-prefetch头值为'2'(PPRRuntime)或'3'(RuntimeShell)的归为'runtime',导航/Server Actions 为undefined(#L22-L41)。带kind的期望只会被对应类型的响应认领,且失败信息会明确提示"该子串出现在了错误类型的响应中",定位问题非常高效。
四、LinkAccordion 模式
4.1 为什么需要 LinkAccordion
Next.js 的 <Link> 在进入视口时(经 IntersectionObserver)触发 prefetch。LinkAccordion 把 <Link> 藏在一个复选框开关后面,从而让你精确控制 prefetch 发生的时刻——只有当你在 act 作用域内显式勾选开关时。
仓库中的真实实现位于 test/e2e/app-dir/segment-cache/staleness/components/link-accordion.tsx,与文档完全一致:
// components/link-accordion.tsx
'use client'
import Link from 'next/link'
import { useState } from 'react'
export function LinkAccordion({ href, children, prefetch }) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{children}
</Link>
) : (
`${children} (link is hidden)`
)}
</>
)
}
真实实现额外带了 LinkProps 类型标注(prefetch?: LinkProps['prefetch']),允许测试显式覆盖 prefetch 行为。
4.2 标准导航模式
永远在同一个 act 作用域内完成"展开手风琴 + 点击链接"两步:
await act(
async () => {
// 1. 展开手风琴 —— Link 进入 DOM,触发 prefetch
const toggle = await browser.elementByCss(
'input[data-link-accordion="/target-page"]'
)
await toggle.click()
// 2. 点击已显示的链接 —— 触发导航
const link = await browser.elementByCss('a[href="/target-page"]')
await link.click()
},
{ includes: 'Expected page content' }
)
五、Hub Pages:返回再访问的标准做法
当你需要"离开某页再回来"以测试数据过期(staleness)时,不要使用 browser.back(),而要使用 Hub 页:每个 hub 都是一张全新页面,其 LinkAccordion 组件初始全部闭合。
Hub 页通过 connection()(next/server)确保自己以动态方式渲染,从而保证导航到 hub 一定产生路由请求,act 才能正确接管导航并等待页面完全渲染。仓库中真实存在的 hub 页 test/e2e/app-dir/segment-cache/staleness/app/per-page-config/hub-a/page.tsx 与文档模式完全吻合:
// app/my-test/hub-a/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { LinkAccordion } from '../../components/link-accordion'
async function Content() {
await connection()
return <div id="hub-a-content">Hub a</div>
}
export default function Page() {
return (
<>
<Suspense fallback="Loading...">
<Content />
</Suspense>
<ul>
<li>
<LinkAccordion href="/my-test/target-page">Target page</LinkAccordion>
</li>
</ul>
</>
)
}
目标页同样通过 LinkAccordion 链回各 hub:
// 在目标页上加指向 hub 页的 LinkAccordion
<LinkAccordion href="/my-test/hub-a">Hub A</LinkAccordion>
完整测试流(访问目标页 → 去 hub → 拨钟 → 从 hub 受控地返回目标页):
// 1. 导航到目标页(首次访问)
await act(
async () => {
/* 展开手风琴、点击链接 */
},
{ includes: 'Target content' }
)
// 2. 导航到 hub-a(全新页面,所有手风琴闭合)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/my-test/hub-a"]'
)
await toggle.click()
const link = await browser.elementByCss('a[href="/my-test/hub-a"]')
await link.click()
},
{ includes: 'Hub a' }
)
// 3. 推进时间
await page.clock.setFixedTime(startDate + 60 * 1000)
// 4. 从 hub 导航回目标页(受控 prefetch)
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/my-test/target-page"]'
)
await toggle.click()
const link = await browser.elementByCss('a[href="/my-test/target-page"]')
await link.click()
}, 'no-requests') // 若数据已过期,则改用 { includes: '...' }
六、Fake Clock:控制 Date.now() 测试缓存过期
Segment cache 的 staleness 检查基于 Date.now(),官方测试使用 Playwright 的 clock API 来接管时间:
async function startBrowserWithFakeClock(url: string) {
let page!: Playwright.Page
const startDate = Date.now()
const browser = await next.browser(url, {
async beforePageLoad(p: Playwright.Page) {
page = p
await p.clock.install()
await p.clock.setFixedTime(startDate)
},
})
const act = createRouterAct(page)
return { browser, page, act, startDate }
}
四条关于假时钟的语义要点(写断言前必须理解):
setFixedTime只改变Date.now()的返回值,定时器仍按真实时间运行;- Segment cache 用
Date.now()做 staleness 判断; - 推进时钟不会触发 IntersectionObserver——只有视口变化才会;
setFixedTime不会触发已挂起的setTimeout/setInterval回调。
真实测试 test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts 中展示了另一种常用操作——page.clock.fastForward(2 * 60 * 1000 + 1) 快进"2 分钟 + 1 毫秒",随后断言 2 分钟过期时间的页面被重新请求,而 4 分钟过期时间的页面返回 'no-requests'。该测试文件还示范了一个重要前提:segment cache staleness 测试只在生产构建下运行,开发模式下整组用例被跳过(if (isNextDev) { return }),且测试项目需在 test/e2e/app-dir/segment-cache/staleness/next.config.js 中开启 Cache Components 并配置过期时间:
const nextConfig = {
cacheComponents: true,
experimental: {
staleTimes: {
dynamic: 30,
},
},
}
七、四大常见 Flakiness 来源及修复
这是原文档中最具实战价值的排雷清单,逐条说明:
7.1 手风琴仍展开时调用 browser.back()
禁止用 browser.back() 返回一个曾展开过手风琴的页面。BFCache 会恢复完整 React 状态(包括 useState 值),先前打开的 Link 会立刻可见,在任何 act 作用域之外触发 IntersectionObserver 回调;若此时缓存数据已过期,不受控的重新 prefetch 就会破坏后续的 no-requests 断言。
唯一安全的用法:当你专门在测试 BFCache 行为本身时。修复方案:改为正向导航到一个全新 hub 页(见上文 Hub Pages 一节)。
7.2 在 act 作用域之外暴露可见的 <Link>
任何处于视口中的 <Link> 都可能随时通过 IntersectionObserver 触发 prefetch。若发生在 act 作用域外,请求不受控,可能干扰后续断言。规则:一律把链接藏在 LinkAccordion 后面,且只在 act 内展开。
7.3 用 retry/轮询等待网络活动
retry()、setTimeout 或任何轮询等待 prefetch/导航稳定的做法天生 flaky。act 会确定性地等待所有路由请求完成后再返回——如果你需要"再等一会儿",说明你该把该动作移进 act 的 scope 或改强断言,而不是加 sleep。
7.4 在同一 act 作用域内导航并等待渲染
响应在 scope 退出前被缓冲,"点击链接后在同一 scope 内读目标页内容"必然死锁。修复:act 返回后再读页面内容。
八、参考文件与延伸阅读
| 资源 | 路径 |
|---|---|
createRouterAct 实现 |
test/lib/router-act.ts |
LinkAccordion 组件 |
test/e2e/app-dir/segment-cache/staleness/components/link-accordion.tsx |
| staleness 示例测试 | test/e2e/app-dir/segment-cache/staleness/(含 segment-cache-stale-time.test.ts、segment-cache-per-page-dynamic-stale-time.test.ts) |
App Shell 专项测试(includeAppShellRequests 标准示例) |
test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts |
| 模式知识源文档 | .agents/skills/router-act/SKILL.md |
segment-cache 测试目录下还有大量同模式用例可作参考,例如 prefetch-app-shell/、force-stale/、revalidation/、cached-navigations/ 等(test/e2e/app-dir/segment-cache/),它们覆盖了 prefetch 调度、强制过期、缓存导航、vary-params 等客户端路由缓存的完整场景。
适用前提小结:Router Act 是 Next.js 仓库内部 E2E 测试基建(user-invocable: false、internal: true),服务于 test/e2e 下自建的小型测试应用;其价值不仅在于直接复用——若你为自己项目的 App Router prefetch/缓存行为编写 E2E 测试,本文的 act 拦截断言、LinkAccordion 视口控制、Hub Page 往返、假时钟控制时间四条手法同样是一套可直接移植的确定性测试设计。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00