Storybook Next.js 框架包实战:用 @storybook/nextjs/navigation.mock 模拟与断言 next/navigation 导航行为
在 Next.js 项目中编写组件的交互测试(play function)时,next/navigation 里的 redirect、useRouter 等 API 依赖真实的 Next.js 运行时,直接在 Storybook 中调用会失败或产生无意义的全局跳转。本文基于仓库片段 nextjs-navigation-mock.md,系统讲解 @storybook/nextjs(或 @storybook/nextjs-vite)提供的 navigation.mock 模块:如何正确导入模拟实现、为什么必须配置 nextjs.appDirectory: true 参数,以及如何结合 storybook/test 的断言工具在 play function 中验证组件对 redirect() 和 router.back() 的调用。读完后你将掌握在隔离环境中测试 App Router 导航逻辑的完整方案,并能读懂其底层 mock 的构建方式。
为什么需要 navigation.mock
Next.js 的 next/navigation 模块(redirect、useRouter、usePathname 等)只有在 Next.js 自身的运行上下文中才能正常工作。Storybook 渲染故事时并不运行完整的 Next.js 路由系统,因此框架包提供了 navigation.mock 子模块,导出 next/navigation 的模拟实现,以及一个 getRouter() 辅助函数——它返回一个被 mock 化的 router 对象,可以对其属性进行操作和断言。
官方文档中该模块的类型声明为:
typeof import('next/navigation') & getRouter: () => ReturnType<typeof import('next/navigation')['useRouter']>
即:它完整保留了 next/navigation 的导出面,额外附加了 getRouter()。这个模块正是官方文档 Next.js 框架指南 中 “Modules / @storybook/nextjs/navigation.mock” 一节讲解的核心内容。
从源码看,mock 的实际实现在 code/frameworks/nextjs/src/export-mocks/navigation/index.ts,其中 redirect、permanentRedirect 是用 storybook/test 的 fn() 包装的 mock,同时保留 Next.js 原始行为(抛出真实的 redirect error),而 useSearchParams、usePathname、useRouter 等则是“透传 mock”——内部仍调用 Next.js 原实现,但允许你对其做 spy 和断言。
前置条件:appDirectory 参数必须为 true
next/navigation 只服务于 App Router,这与 Next.js 应用本身的行为一致。因此在 Storybook 中使用 navigation 相关 mock 前,必须将 nextjs.appDirectory 参数设为 true。
该参数的规格(引自 nextjs.mdx 的 Parameters 章节):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
nextjs.appDirectory |
boolean |
false |
当故事导入的组件使用了 next/navigation 时,必须置为 true。作为参数,它可以应用到单个故事(story parameters)、组件的所有故事(meta parameters)或整个 Storybook(project parameters) |
nextjs.navigation |
{ asPath?, pathname?, query?, segments? } |
{ segments: [] } |
传入 next/navigation 上下文的 router 对象,可预置初始路由状态 |
为什么这个参数至关重要?可以看框架包 preview 加载器的源码 code/frameworks/nextjs/src/preview.tsx:
export const loaders: Addon_LoaderFunction = async ({ globals, parameters }) => {
const { router, appDirectory } = parameters.nextjs ?? {};
if (appDirectory) {
createNavigation(router); // 👈 App Router:创建 next/navigation 的 mock
} else {
createRouter({ locale: globals.locale, ...router });
}
};
也就是说,createNavigation(即初始化 navigation.mock 内部 router API)只有在 appDirectory 为真时才会执行。如果遗漏这个参数,调用 getRouter() 时会抛出 NextjsRouterMocksNotAvailable 错误(在 navigation/index.ts 的 getRouter 中定义)。此外,框架包的 App Router Provider 会把 getRouter() 的返回值注入 AppRouterContext,使得组件内 useRouter() 拿到的正是这个可断言的 mock 对象。
导入规则:必须带上 .mock 后缀
@storybook/nextjs 在 package.json 中显式导出了 ./navigation.mock 子路径入口(构建时通过 exportEntries: ['./navigation.mock'] 生成对应产物,见 build-config.ts)。
使用时的两条导入规则:
- 把文档中的
your-framework占位符替换为nextjs(Webpack 构建)或nextjs-vite(Vite 构建),二者提供的 API 一致,官方文档 nextjs-vite 指南 同样引用了本篇示例; - TypeScript 中导入路径必须包含
.mock部分(如@storybook/nextjs/navigation.mock),否则 mock 的类型推导不正确。
完整故事示例:在 play function 中断言导航调用
下面的示例直接继承自原文档(覆盖 CSF 3 与 CSF Next 🧪 两种写法),演示两个常见场景:组件未认证时调用 redirect('/login', 'replace'),以及用户点击 “Go back” 按钮后调用 router.back()。
CSF 3(JavaScript)
import { expect } from 'storybook/test';
// Replace your-framework with nextjs or nextjs-vite
import { redirect, getRouter } from '@storybook/your-framework/navigation';
import MyForm from './my-form';
export default {
component: MyForm,
parameters: {
nextjs: {
// 👇 As in the Next.js application, next/navigation only works using App Router
appDirectory: true,
},
},
};
export const Unauthenticated = {
async play() {
// 👇 Assert that your component called redirect()
await expect(redirect).toHaveBeenCalledWith('/login', 'replace');
},
};
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();
},
};
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 { redirect, getRouter } from '@storybook/your-framework/navigation.mock';
import MyForm from './my-form';
const meta = {
component: MyForm,
parameters: {
nextjs: {
// 👇 As in the Next.js application, next/navigation only works using App Router
appDirectory: true,
},
},
} satisfies Meta<typeof MyForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unauthenticated: Story = {
async play() {
// 👇 Assert that your component called redirect()
await expect(redirect).toHaveBeenCalledWith('/login', 'replace');
},
};
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();
},
};
CSF Next 🧪(基于 preview.meta 的工厂写法)
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 { redirect, getRouter } from '@storybook/your-framework/navigation.mock';
import preview from '../.storybook/preview';
import MyForm from './my-form';
const meta = preview.meta({
component: MyForm,
parameters: {
nextjs: {
// 👇 As in the Next.js application, next/navigation only works using App Router
appDirectory: true,
},
},
});
export const Unauthenticated = meta.story({
async play() {
// 👇 Assert that your component called redirect()
await expect(redirect).toHaveBeenCalledWith('/login', 'replace');
},
});
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();
},
});
JavaScript 版本同样存在 CSF Next 写法,与 TS 版本结构完全相同,仅去掉了类型注解,这里不再赘述。
逐行解析:断言是如何生效的
以 Unauthenticated 故事为例,expect(redirect).toHaveBeenCalledWith('/login', 'replace') 能工作,是因为源码中 redirect 被定义为带名字的 fn() mock:
// 摘自 code/frameworks/nextjs/src/export-mocks/navigation/index.ts
export const redirect = fn(
(url: string, type: actual.RedirectType = actual.RedirectType.push): never => {
throw getRedirectError(url, type, RedirectStatusCode.SeeOther);
}
).mockName('next/navigation::redirect');
- mock 用
storybook/test的fn()构造,因此天然记录所有调用,支持toHaveBeenCalledWith等断言; - 其实现仍然抛出 Next.js 真实的 redirect error(
getRedirectError+303 See Other状态码),意味着如果你的组件依赖捕获 redirect 错误做逻辑分支,行为与线上 App Router 一致; mockName让失败信息更可读。
getRouter() 返回的对象则由 createNavigation 构建,包含 push、replace、forward、back、prefetch、refresh 六个 fn() mock,每个都带有 next/navigation::useRouter().xxx 的可读名字;createNavigation 还接受 overrides 参数——这正是 nextjs.navigation 参数在 loader 中被透传给 createNavigation(router) 的用途,允许你预置路由状态或替换个别导航动作的行为。
由于 mock 全部基于 storybook/test 的 fn(),你可以使用任意 mock 工具(如 getRouter().push.mock.calls)来检查历史调用,这也是官方文档中 “mock utilities” 说法的底层来源。
与 Pages Router 的 router.mock 的区别
需要注意区分两个相似模块:
@storybook/nextjs/navigation.mock:模拟 App Router 的next/navigation,对应参数nextjs.appDirectory: true和nextjs.navigation(支持segments);@storybook/nextjs/router.mock:模拟 Pages Router 的next/router,对应参数nextjs.router(asPath/pathname/query),不需要appDirectory参数。
框架包内置模板中的故事 Navigation.stories.tsx 与 Router.stories.tsx 分别演示了两套写法,可作为 create-storybook 初始化项目的参考起点;ServerActions.stories.tsx 则进一步展示了在 server actions 场景中用 waitFor(() => expect(getRouter().push).toHaveBeenCalled()) 断言异步导航的完整写法。
实践要点小结
- 导入路径:JS/TS 均可从
@storybook/nextjs/navigation.mock(或nextjs-vite)导入;TS 项目务必保留.mock后缀以获得正确的 mock 类型; - 参数必配:只要组件用到
next/navigation,就在 story 级、meta 级或 preview 全局设置nextjs.appDirectory: true,否则getRouter()会抛出 “mocks not available” 错误; - 断言方式:对
redirect这类顶层函数直接用expect(redirect).toHaveBeenCalledWith(...);对useRouter返回的实例方法(如back),先getRouter()拿到 mock 对象再断言; - 交互触发:配合
canvas.findByText+userEvent.click先完成用户操作,再对 mock 做断言,构成完整的 play function 交互测试闭环; - 适用版本:以上行为以当前仓库中
code/frameworks/nextjs的实现为准,其中 navigation mock 的 passthrough 部分标注了 “as of Next v14.2.0”,若升级 Next.js 大版本,建议核对 navigation/index.ts 中透传列表与目标版本的 API 对齐。
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