Storybook Next.js 框架实战:用 @storybook/nextjs/router.mock 模拟并断言 next/router 交互行为
本文围绕 Storybook 官方文档中的 Next.js 框架路由 Mock 用法展开,讲解如何在 Story 的 play function 中通过 @storybook/nextjs/router.mock(或 @storybook/nextjs-vite/router.mock)获取一个可断言的 Next.js Pages Router 单例对象,完整给出 CSF 3 与实验性 CSF Next 语法的断言示例,并结合仓库源码剖析 Mock 的创建时机、默认状态、覆盖机制以及与 loader、decorator、Webpack 别名的联动关系。读完本文,你可以为依赖 useRouter() 的 Next.js 组件编写可靠的交互测试,并对路由跳转(push/replace/back/forward 等)调用做出精确断言。
为什么 Story 中的组件需要 Router Mock
在 Storybook 中单独渲染一个组件时,Next.js 真实的路由环境并不存在:next/router 内部的单例 router 对象依赖浏览器历史和 Next.js 运行时,直接导入会拿到一个状态不完整(甚至不可用)的对象。因此 @storybook/nextjs 框架为 preview 侧提供了一套 Mock 模块,把 next/router 的导出替换为受控的替身,并额外暴露一个 getRouter 函数,返回当前 Story 生效的那个 Mock router,供你在测试中断言“组件是否调用了 router.back()”这类行为。
官方文档对该模块的定义(见 Next.js 框架文档):
- 模块路径:
@storybook/nextjs/router.mock - 类型:
typeof import('next/router') & getRouter: () => ReturnType<typeof import('next/router')['useRouter']> - 能力:导出
next/router模块各导出的 Mock 实现;getRouter返回一个被 Mock 过的useRouterrouter对象,其属性可被操纵(manipulate)和被断言(assert on),可在 Story 的 play function 中使用。
使用它的前提是你的项目以 @storybook/nextjs(Webpack 构建)或 @storybook/nextjs-vite(Vite 构建)作为框架。示例代码中的占位符 your-framework 需要替换为这两者之一。
完整用法:在 play function 中断言 router 调用
以下示例继承自仓库官方文档片段 nextjs-router-mock.md,场景是:组件 MyForm 内有一个 “Go back” 按钮,点击后调用 router.back()。
CSF 3 语法(JavaScript)
import { expect } from 'storybook/test';
// Replace your-framework with nextjs or nextjs-vite
import { getRouter } from '@storybook/your-framework/router';
import MyForm from './my-form';
export default {
component: MyForm,
};
export const GoBack = {
async play({ canvas, userEvent }) {
const backBtn = await canvas.findByText('Go back');
await userEvent.click(backBtn);
// Assert that your component called back()
await expect(getRouter().back).toHaveBeenCalled();
},
};
要点:
expect来自storybook/test,play function 的上下文解构出canvas(基于 Testing Library 的查询作用域)与userEvent(用户事件模拟)。getRouter().back是一个Mock函数,因此可以直接用expect(...).toHaveBeenCalled()断言它被调用过。canvas.findByText('Go back')使用异步查找,适配组件渲染完成前按钮尚不可用的情况。
CSF 3 语法(TypeScript)
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
import { expect } from 'storybook/test';
// Must include the `.mock` portion of filename to have mocks typed correctly
import { getRouter } from '@storybook/your-framework/router.mock';
import MyForm from './my-form';
const meta = {
component: MyForm,
} satisfies Meta<typeof MyForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const GoBack: Story = {
async play({ canvas, userEvent }) {
const backBtn = await canvas.findByText('Go back');
await userEvent.click(backBtn);
// Assert that your component called back()
await expect(getRouter().back).toHaveBeenCalled();
},
};
注意注释中的关键约束:TypeScript 场景下必须从 router.mock(带 .mock 后缀的路径)导入。原因是你的组件源码里 import { useRouter } from 'next/router' 拿到的是 Next.js 官方类型(普通函数签名),无法支持 toHaveBeenCalled 这类 Mock 断言的类型检查;而 router.mock 模块的类型声明中,back、push 等属性被声明为 storybook/test 的 Mock,断言才能通过类型检查。运行时两者指向同一个模块,这一点由构建期别名保证(见下文 Webpack 别名 一节)。
实验性 CSF Next 语法
仓库文档同时提供了实验性的 “CSF Next” 变体(对应 features: { experimentalTestSyntax } 一类的新式测试写法),Story 通过 preview.meta({...}) 与 meta.story({...}) 工厂定义:
import { expect } from 'storybook/test';
/*
* Replace your-framework with nextjs or nextjs-vite
* Must include the `.mock` portion of filename to have mocks typed correctly
*/
import { getRouter } from '@storybook/your-framework/router.mock';
import preview from '../.storybook/preview';
import MyForm from './my-form';
const meta = preview.meta({
component: MyForm,
});
export const GoBack = meta.story({
async play({ canvas, userEvent }) {
const backBtn = await canvas.findByText('Go back');
await userEvent.click(backBtn);
// Assert that your component called back()
await expect(getRouter().back).toHaveBeenCalled();
},
};
import { expect } from 'storybook/test';
// Replace your-framework with nextjs or nextjs-vite
import { getRouter } from '@storybook/your-framework/router';
import preview from '../.storybook/preview';
import MyForm from './my-form';
const meta = preview.meta({
component: MyForm,
});
export const GoBack = meta.story({
async play({ canvas, userEvent }) {
const backBtn = await canvas.findByText('Go back');
await userEvent.click(backBtn);
// Assert that your component called back()
await expect(getRouter().back).toHaveBeenCalled();
},
});
两个变体中 play function 的断言逻辑与 CSF 3 完全一致,差异仅在 meta/story 的组织方式(经由 ../.storybook/preview 的工厂函数)。如果你的项目尚未启用该实验特性,请以 CSF 3 写法为准。
router.mock 模块的完整导出面
结合 router mock 实现源码,该模块的导出可分为三类:
getRouter():返回当前 Story 已创建的 Mock router(NextRouter类型)。如果 router 尚未被创建(例如框架 loader 未运行、或处于 App Router 模式),会抛出NextjsRouterMocksNotAvailable错误——该错误类定义于 preview-errors.ts,并带有importType: 'next/router'标识,便于区分是 pages router 还是 app router 的 Mock 缺失。createRouter(overrides):内部 API(源码中标注@internal),供框架的 preview loader 调用,传入的overrides对应你写在parameters.nextjs.router里的状态覆盖(见 定制 router 状态)。- 对
next/router的再导出与 passthrough Mock:export * from 'next/dist/client/router.js'与默认导出singletonRouter,保证import Router, { useRouter } from 'next/router'在 preview 中可用;useRouter与withRouter被包装为名为next/router::useRouter、next/router::withRouter的 passthrough Mock(源码注释标明对齐 Next v14.2.0),保留原实现的同时允许被观察调用。
@storybook/nextjs-vite 框架提供同名能力,对应源码位于 nextjs-vite 的 router mock,因此文档中 your-framework 占位符两种取法都成立。
源码级剖析:Mock router 是如何构成的
阅读 export-mocks/router/index.ts 可以看到 Mock 的三层结构。
默认路由状态
const defaultRouterState = {
route: '/',
asPath: '/',
basePath: '/',
pathname: '/',
query: {},
isFallback: false,
isLocaleDomain: false,
isReady: true,
isPreview: false,
};
这意味着在不做任何参数配置时,Story 中的组件拿到的是一个“已就绪、位于根路径”的路由:isReady: true 避免了依赖 router.isReady 的组件卡在加载中。
被 Mock 的路由动作
createRouter 用 storybook/test 的 fn() 为每个路由动作创建 Mock,并显式命名(这些名称会出现在交互测试报告中,方便定位):
| Mock 属性 | mockName | 默认返回值 |
|---|---|---|
push |
next/router::useRouter().push |
Promise.resolve(true) |
replace |
next/router::useRouter().replace |
Promise.resolve(true) |
reload |
next/router::useRouter().reload |
空操作 |
back |
next/router::useRouter().back |
空操作 |
forward |
next/router::useRouter().forward |
空操作 |
prefetch |
next/router::useRouter().prefetch |
Promise.resolve() |
beforePopState |
next/router::useRouter().beforePopState |
空操作 |
events.on / events.off / events.emit |
next/router::useRouter().events.* |
空操作 |
由于 push/replace/prefetch 返回已解决的 Promise,依赖这些调用返回值的组件行为与真实 API 一致,同时断言 await expect(routerMock.prefetch).toHaveBeenCalledWith('/some-path') 这类参数级检查也是成立的。
覆盖(overrides)机制
createRouter(overrides) 接受一个 Partial<NextRouter>:
- 若
overrides中定义了某个动作(如prefetch: () => {...}),会用自定义实现替换对应 Mock,但仍包裹一层fn()以便继续断言调用; - 若提供了
overrides.events,同样按 key 逐个替换events.on/off/emit; - 最终
routerAPI按defaultRouterState → overrides → routerActions → events的顺序合并,即:状态覆盖 > 默认状态,动作覆盖 > 默认 Mock 动作。
与真实单例的桥接
创建完成后,createRouter 做了两件关键事:
// overwrite the singleton router from next/router
singletonRouter.router = routerAPI;
singletonRouter.readyCallbacks.forEach((cb) => cb());
singletonRouter.readyCallbacks = [];
它将 next/dist/client/router.js 的内部单例 singletonRouter 的 router 指针替换为 Mock,并触发所有 pending 的 readyCallbacks(即调用过 router.ready(fn) 的回调立即执行)。这就是为什么断言既可以在 getRouter() 返回的对象上做,也可以直接检查 next/router 默认导出(import Router from 'next/router')的状态——仓库自带示例故事 Router.stories.tsx 的 play function 中就有 await expect(Router.pathname).toBe('/hello') 这一步,分别验证了 useRouter() 返回值与默认导出两处都能观察到覆盖后的状态。
运行时接线:loader、decorator 与构建别名
Mock 并非静态存在,而是由框架在运行时按 Story 装配的。三个环节值得了解(以下基于 @storybook/nextjs 的源码结构):
Loader 决定何时创建 Mock
preview.tsx 导出的 loader 是每个 Story 渲染前执行的装配点:
export const loaders: Addon_LoaderFunction = async ({ globals, parameters }) => {
const { router, appDirectory } = parameters.nextjs ?? {};
if (appDirectory) {
createNavigation(router);
} else {
createRouter({
locale: globals.locale,
...router,
});
}
};
两个推论:
- 默认走 Pages Router 分支:只有当参数
nextjs.appDirectory为true时才创建next/navigation的 Mock;否则调用createRouter,并把全局locale作为基础值合并进你的parameters.nextjs.router覆盖。 - 若你在
appDirectory: true的 Story 里误用@storybook/nextjs/router.mock的getRouter(),会得到前文提到的NextjsRouterMocksNotAvailable异常——这是两类 Mock 互斥的体现。
Decorator 把 Mock 注入 React 上下文
routing/decorator.tsx 中的 RouterDecorator 按 parameters.nextjs?.appDirectory 二选一:
- App 目录:用
AppRouterProvider+RedirectBoundary包裹 Story(处理next/navigation与redirect); - Pages 路由:用 PageRouterProvider 包裹,其实现只有一行核心逻辑——
<RouterContext.Provider value={getRouter()}>,即把 Next.js 内部RouterContext的 Provider 值直接替换为 Mock。组件中useRouter()读到的router.pathname、router.query等状态,正是这里注入的对象。
注意 PageRouterProvider 顶部有一段源码注释说明:getRouter 必须经由包名(而非相对路径)导入,才能在 ESM 与 CJS 多个入口中保持单例。
Webpack 别名把 next/router 指向 Mock 模块
export-mocks/webpack.ts 定义了预览构建的模块映射:
const mapping = {
'next/headers': '@storybook/nextjs/headers.mock',
'next/navigation': '@storybook/nextjs/navigation.mock',
'next/router': '@storybook/nextjs/router.mock',
'next/cache': '@storybook/nextjs/cache.mock',
'next/link': '@storybook/nextjs/link.mock',
...getCompatibilityAliases(),
};
configureNextExportMocks 会把这份映射并入 Webpack resolve.alias。这解释了两个现象:
- 运行时:组件里写的
import { useRouter } from 'next/router'实际加载的就是 Mock 模块,useRouter因此是被命名过的 passthrough Mock; - 类型层:编辑器里
next/router的签名仍是 Next.js 官方类型,所以 TS 故事文件必须从@storybook/your-framework/router.mock导入才能获得Mock类型——这正是文档片段中强调 “Must include the.mockportion of filename” 的根本原因。
该映射表同时对外暴露为 getPackageAliases()(@storybook/nextjs/export-mocks 入口),供 Jest/Vitest 等独立测试环境配置 moduleNameMapper,使 portable stories 在非 Storybook 环境中也能解析同一套 Mock。
定制 router 状态:nextjs.router 参数
Mock 的价值一半在“断言”,另一半在“造场景”:通过 parameters.nextjs.router 预设路由状态,让组件渲染在指定的路径与查询参数上。
仓库自带模板故事 Router.stories.tsx 是一个完整可参考的例子,其 meta 配置为:
export default {
component: Component,
parameters: {
nextjs: {
router: {
pathname: '/hello',
query: {
foo: 'bar',
},
prefetch: () => {
console.log('custom prefetch');
},
},
},
},
} as Meta<typeof Component>;
对应的 play function 展示了四种典型断言模式:
export const Default: StoryObj<typeof Component> = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const routerMock = getRouter();
await step('Router property overrides should be available in useRouter fn', async () => {
await expect(Router.pathname).toBe('/hello');
await expect(Router.query).toEqual({ foo: 'bar' });
});
await step('Asserts whether forward hook is called', async () => {
const forwardBtn = await canvas.findByText('Go forward');
await userEvent.click(forwardBtn);
await expect(routerMock.forward).toHaveBeenCalled();
});
await step('Asserts whether custom prefetch hook is called', async () => {
const prefetchBtn = await canvas.findByText('Prefetch');
await userEvent.click(prefetchBtn);
await expect(routerMock.prefetch).toHaveBeenCalledWith('/prefetched-html');
});
},
};
按 框架文档 的参数定义,nextjs.router 支持的标准状态字段为:
{
asPath?: string;
pathname?: string;
query?: Record<string, string>;
}
此外(从源码结构看),loader 会将全局 globals.locale 一并传入 createRouter,且 overrides 允许直接提供自定义动作实现(如上面的 prefetch),即“状态 + 行为”两层都可定制。参数可分别作用于单个 Story、组件的 meta 或全局 preview,作用粒度与 Storybook 参数的常规机制一致。
适用边界与常见陷阱
- Pages Router 与 App Router 二选一:
nextjs.appDirectory: true时框架创建的是navigation.mock,此时应使用@storybook/your-framework/navigation.mock的getRouter();对router.mock的断言将因 Mock 未创建而抛错。 getRouter()的调用时机:它在 loader 运行、Story 渲染管线启动后才有效;play function 位于该管线内,是安全的调用位置。- 类型与运行时的分界:JS 项目从
router与从router.mock导入没有类型问题;TS 项目务必使用.mock后缀路径。 - 独立测试环境:若在 Jest/Vitest 中通过 portable stories 复用这些故事,需要用
getPackageAliases()配置模块映射,否则next/router无法解析到 Mock 模块。
仓库内延伸阅读
- 文档片段原文:docs/_snippets/nextjs-router-mock.md
- 承载该用法的完整框架文档:docs/get-started/frameworks/nextjs.mdx、docs/get-started/frameworks/nextjs-vite.mdx
- Mock 实现:code/frameworks/nextjs/src/export-mocks/router/index.ts
- 别名与装配:code/frameworks/nextjs/src/export-mocks/webpack.ts、code/frameworks/nextjs/src/preview.tsx
- 路由 Provider:code/frameworks/nextjs/src/routing/decorator.tsx、code/frameworks/nextjs/src/routing/page-router-provider.tsx
- 官方模板示例:code/frameworks/nextjs/template/stories/Router.stories.tsx(Pages Router)、code/frameworks/nextjs/template/stories/Navigation.stories.tsx(App Router)
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