首页
/ Storybook Next.js 框架包实战:用 @storybook/nextjs/navigation.mock 模拟与断言 next/navigation 导航行为

Storybook Next.js 框架包实战:用 @storybook/nextjs/navigation.mock 模拟与断言 next/navigation 导航行为

2026-09-07 16:09:25作者:伍希望

在 Next.js 项目中编写组件的交互测试(play function)时,next/navigation 里的 redirectuseRouter 等 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 模块(redirectuseRouterusePathname 等)只有在 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,其中 redirectpermanentRedirect 是用 storybook/testfn() 包装的 mock,同时保留 Next.js 原始行为(抛出真实的 redirect error),而 useSearchParamsusePathnameuseRouter 等则是“透传 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.tsgetRouter 中定义)。此外,框架包的 App Router Provider 会把 getRouter() 的返回值注入 AppRouterContext,使得组件内 useRouter() 拿到的正是这个可断言的 mock 对象。

导入规则:必须带上 .mock 后缀

@storybook/nextjspackage.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/testfn() 构造,因此天然记录所有调用,支持 toHaveBeenCalledWith 等断言;
  • 其实现仍然抛出 Next.js 真实的 redirect error(getRedirectError + 303 See Other 状态码),意味着如果你的组件依赖捕获 redirect 错误做逻辑分支,行为与线上 App Router 一致;
  • mockName 让失败信息更可读。

getRouter() 返回的对象则由 createNavigation 构建,包含 pushreplaceforwardbackprefetchrefresh 六个 fn() mock,每个都带有 next/navigation::useRouter().xxx 的可读名字;createNavigation 还接受 overrides 参数——这正是 nextjs.navigation 参数在 loader 中被透传给 createNavigation(router) 的用途,允许你预置路由状态或替换个别导航动作的行为。

由于 mock 全部基于 storybook/testfn(),你可以使用任意 mock 工具(如 getRouter().push.mock.calls)来检查历史调用,这也是官方文档中 “mock utilities” 说法的底层来源。

与 Pages Router 的 router.mock 的区别

需要注意区分两个相似模块:

  • @storybook/nextjs/navigation.mock:模拟 App Routernext/navigation,对应参数 nextjs.appDirectory: truenextjs.navigation(支持 segments);
  • @storybook/nextjs/router.mock:模拟 Pages Routernext/router,对应参数 nextjs.routerasPath / pathname / query),不需要 appDirectory 参数。

框架包内置模板中的故事 Navigation.stories.tsxRouter.stories.tsx 分别演示了两套写法,可作为 create-storybook 初始化项目的参考起点;ServerActions.stories.tsx 则进一步展示了在 server actions 场景中用 waitFor(() => expect(getRouter().push).toHaveBeenCalled()) 断言异步导航的完整写法。

实践要点小结

  1. 导入路径:JS/TS 均可从 @storybook/nextjs/navigation.mock(或 nextjs-vite)导入;TS 项目务必保留 .mock 后缀以获得正确的 mock 类型;
  2. 参数必配:只要组件用到 next/navigation,就在 story 级、meta 级或 preview 全局设置 nextjs.appDirectory: true,否则 getRouter() 会抛出 “mocks not available” 错误;
  3. 断言方式:对 redirect 这类顶层函数直接用 expect(redirect).toHaveBeenCalledWith(...);对 useRouter 返回的实例方法(如 back),先 getRouter() 拿到 mock 对象再断言;
  4. 交互触发:配合 canvas.findByText + userEvent.click 先完成用户操作,再对 mock 做断言,构成完整的 play function 交互测试闭环;
  5. 适用版本:以上行为以当前仓库中 code/frameworks/nextjs 的实现为准,其中 navigation mock 的 passthrough 部分标注了 “as of Next v14.2.0”,若升级 Next.js 大版本,建议核对 navigation/index.ts 中透传列表与目标版本的 API 对齐。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388