Storybook Next.js 框架包实战:用 @storybook/nextjs/navigation.mock 模拟与断言 next/navigation 导航行为
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
在 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 对齐。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考