ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

Storybook Next.js 框架包实战:用 @storybook/nextjs/navigation.mock 模拟与断言 next/navigation 导航行为 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.mockNext.js 的next/navigation模块redirect、useRouter、usePathname等只有在 Next.js 自身的运行上下文中才能正常工作。Storybook 渲染故事时并不运行完整的 Next.js 路由系统因此框架包提供了navigation.mock子模块导出next/navigation的模拟实现以及一个getRouter()辅助函数——它返回一个被 mock 化的 router 对象可以对其属性进行操作和断言。官方文档中该模块的类型声明为typeof import(next/navigation) getRouter: () ReturnTypetypeof 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 参数必须为 truenext/navigation只服务于 App Router这与 Next.js 应用本身的行为一致。因此在 Storybook 中使用 navigation 相关 mock 前必须将nextjs.appDirectory参数设为true。该参数的规格引自 nextjs.mdx 的 Parameters 章节参数类型默认值说明nextjs.appDirectorybooleanfalse当故事导入的组件使用了next/navigation时必须置为true。作为参数它可以应用到单个故事story parameters、组件的所有故事meta parameters或整个 Storybookproject parametersnextjs.navigation{ asPath?, pathname?, query?, segments? }{ segments: [] }传入next/navigation上下文的 router 对象可预置初始路由状态为什么这个参数至关重要可以看框架包 preview 加载器的源码 code/frameworks/nextjs/src/preview.tsxexport 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占位符替换为nextjsWebpack 构建或nextjs-viteVite 构建二者提供的 API 一致官方文档 nextjs-vite 指南 同样引用了本篇示例TypeScript 中导入路径必须包含.mock部分如storybook/nextjs/navigation.mock否则 mock 的类型推导不正确。完整故事示例在 play function 中断言导航调用下面的示例直接继承自原文档覆盖 CSF 3 与 CSF Next 两种写法演示两个常见场景组件未认证时调用redirect(/login, replace)以及用户点击 “Go back” 按钮后调用router.back()。CSF 3JavaScriptimport { 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 3TypeScript// 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 Metatypeof MyForm; export default meta; type Story StoryObjtypeof 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 errorgetRedirectError303 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支持segmentsstorybook/nextjs/router.mock模拟Pages Router的next/router对应参数nextjs.routerasPath/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.findByTextuserEvent.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),仅供参考
返回列表