首页
/ Storybook Next.js 框架实战:用 @storybook/nextjs/router.mock 模拟并断言 next/router 交互行为

Storybook Next.js 框架实战:用 @storybook/nextjs/router.mock 模拟并断言 next/router 交互行为

2026-09-07 16:38:28作者:田桥桑Industrious

本文围绕 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 过的 useRouter router 对象,其属性可被操纵(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 模块的类型声明中,backpush 等属性被声明为 storybook/testMock,断言才能通过类型检查。运行时两者指向同一个模块,这一点由构建期别名保证(见下文 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 实现源码,该模块的导出可分为三类:

  1. getRouter():返回当前 Story 已创建的 Mock router(NextRouter 类型)。如果 router 尚未被创建(例如框架 loader 未运行、或处于 App Router 模式),会抛出 NextjsRouterMocksNotAvailable 错误——该错误类定义于 preview-errors.ts,并带有 importType: 'next/router' 标识,便于区分是 pages router 还是 app router 的 Mock 缺失。
  2. createRouter(overrides):内部 API(源码中标注 @internal),供框架的 preview loader 调用,传入的 overrides 对应你写在 parameters.nextjs.router 里的状态覆盖(见 定制 router 状态)。
  3. next/router 的再导出与 passthrough Mock
    • export * from 'next/dist/client/router.js' 与默认导出 singletonRouter,保证 import Router, { useRouter } from 'next/router' 在 preview 中可用;
    • useRouterwithRouter 被包装为名为 next/router::useRouternext/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 的路由动作

createRouterstorybook/testfn() 为每个路由动作创建 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
  • 最终 routerAPIdefaultRouterState → 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 的内部单例 singletonRouterrouter 指针替换为 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.appDirectorytrue 时才创建 next/navigation 的 Mock;否则调用 createRouter,并把全局 locale 作为基础值合并进你的 parameters.nextjs.router 覆盖。
  • 若你在 appDirectory: true 的 Story 里误用 @storybook/nextjs/router.mockgetRouter(),会得到前文提到的 NextjsRouterMocksNotAvailable 异常——这是两类 Mock 互斥的体现。

Decorator 把 Mock 注入 React 上下文

routing/decorator.tsx 中的 RouterDecoratorparameters.nextjs?.appDirectory 二选一:

  • App 目录:用 AppRouterProvider + RedirectBoundary 包裹 Story(处理 next/navigationredirect);
  • Pages 路由:用 PageRouterProvider 包裹,其实现只有一行核心逻辑——<RouterContext.Provider value={getRouter()}>,即把 Next.js 内部 RouterContext 的 Provider 值直接替换为 Mock。组件中 useRouter() 读到的 router.pathnamerouter.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 .mock portion 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.mockgetRouter();对 router.mock 的断言将因 Mock 未创建而抛错。
  • getRouter() 的调用时机:它在 loader 运行、Story 渲染管线启动后才有效;play function 位于该管线内,是安全的调用位置。
  • 类型与运行时的分界:JS 项目从 router 与从 router.mock 导入没有类型问题;TS 项目务必使用 .mock 后缀路径。
  • 独立测试环境:若在 Jest/Vitest 中通过 portable stories 复用这些故事,需要用 getPackageAliases() 配置模块映射,否则 next/router 无法解析到 Mock 模块。

仓库内延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐