返回博客

Storybook 中的类型安全模块 Mocking

一种新的、基于标准的 Mocking 方法

loading
Jeppe Reinhold
@DrReinhold
loading
Kasper Peulen
@KasperPeulen

在隔离环境中开发和测试 UI,一致性至关重要。

理想情况下,无论何时何人查看,无论后端是否正常工作,您的 Storybook stories 都应始终渲染相同的 UI。故事的相同输入应始终产生相同的输出

当 UI 的唯一输入是传递给组件的 props 时,这很简单。如果您的组件依赖于 context provider 中的数据,您可以通过使用 decorators 包装故事来模拟它们。对于输入从网络获取的 UI,有一个非常流行的 Mock Service Worker 插件,可以确定性地模拟网络请求

但如果您的组件依赖于其他来源,例如浏览器 API,如读取用户的样式偏好、localStorage 或 cookie 中的数据怎么办?或者如果您的组件根据当前日期或时间表现不同怎么办?或者您的组件使用了元框架 API,如 Next.js 的 next/router 怎么办?

在 Storybook 中模拟这些类型的输入历来很困难。这就是我们今天通过在 Storybook 8.1 中进行模块 mocking 所解决的问题!我们的方法简单、类型安全且基于标准。它倾向于明确性和调试清晰度,而非不透明/专有的模块 API。我们对此感到欣慰:Epic Stack 创建者 Kent C. Dodd 推荐对绝对导入使用类似方法,并且 React Server Component 架构师 Seb Markbåge 直接启发了 Storybook 的 mocking。

👉
注意:这项工作还使我们能够在浏览器中的 Storybook 中模拟 Node-only 代码并测试 React Server Components (RSCs)。我们将在未来的博客文章中分享更多相关信息。敬请关注!

什么是模块 mocking?

模块 mocking 是一种技术,您可以用一个一致的、独立的替代品替换组件直接或间接导入的模块。在单元测试中,这有助于在可重现的状态下测试代码。在 Storybook 中,这可用于渲染和测试以有趣方式检索数据的组件。

例如,考虑一个用户可配置的 Dashboard 组件,它允许用户选择显示哪些信息,并将这些设置存储在浏览器的本地存储中。

Network Monitoring Dashboard. Monitor the health and performance of your network. 3 cards. 1: Network Utilization. 72%. Arrow up 5%. 2: Bandwidth Usage. 250 Mbps. Arrow up 10%. 3: Device Uptime. 98.7%. Arrow up 0.5%.
一个 Dashboard 组件

这实现为一个 settings 数据访问层,负责将用户设置读写到本地存储,以及一个显示组件 Dashboard,负责 UI。

// lib/settings.ts
export const getDashboardLayout = () => {
  const layout = window.localStorage.getItem('dashboard.layout');
  return layout ? parseLayout(layout) : [];
};
// components/Dashboard.tsx
import { getDashboardLayout } from '../lib/settings.ts';

export const Dashboard = (props) => {
  const layout = getDashboardLayout();
  // logic to display layout
} 

为了测试 Dashboard 组件,我们希望创建一组不同布局的示例,以覆盖关键状态。为简单起见,且不失一般性,我们只关注读取布局的部分。

在整篇文章中,我们将以此为例来解释模块 mocking、我们如何实现它,以及我们的方法相对于其他实现的优势。

现有方法:专有 API

像 Jest 和 Vitest 这样的流行单元测试工具都提供了灵活的模块 mocking 机制。例如,它们会自动在相邻的 mocks 目录中查找 mock 文件

// lib/__mocks__/settings.ts
export const getDashboardLayout = () => ([ /* dummy data here */ ]);

此外,它们还提供命令式 API,用于在测试文件中声明 mock。

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => ([ /* dummy data here */])),
});

这看起来是一个简单的 API,但实际上,这段代码在底层触发了复杂且有些神奇的文件转换,用 mock 替换了导入。因此,对代码的微小更改可能会以令人困惑的方式破坏 mocking。例如,以下变体就会失败。

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

const dummyLayout = [ /* dummy data here */];
vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => dummyLayout), // FAIL!!!
});

但我们的目标并非贬低这些优秀的工具。相反,我们希望探索如何使用新的、基于标准的方法更好地进行 mocking。

我们的方法:子路径导入 (Subpath Imports)

Storybook 中的模块 mocking 利用了 子路径导入 (Subpath Imports) 标准,该标准可通过任何 JS 项目的核心 package.json 文件中的 imports 字段进行配置,作为在整个项目中导入 mock 的管道。

对我们而言,这种方法的超能力之一在于,就像 package.jsonexports 一样,package.jsonimports 也可以是有条件的,根据运行时环境改变导入路径。这意味着您可以调整 package.json,使其在 Storybook 中导入 mocked 模块,而在其他地方导入实际模块!

子路径导入 (Subpath Imports) 最早引入于 Node.js,现在也已在 JS 生态系统中得到支持,包括 TypeScript(自版本 5.4 起)、Webpack、Vite、Jest、Vitest 等等。

延续上面的例子,以下是您如何模拟 ./lib/settings.ts 模块:

{
  "imports": {
    "#lib/settings": {
      "storybook": "./lib/settings.mock.ts",
      "default": "./lib/settings.ts"
    },
    "#*": [ // fallback for non-mocked absolute imports
      "./*",
      "./*.ts",
      "./*.tsx"
    ]
  }
}

这里我们指示模块解析器,在 Storybook 中,所有从 #lib/settings 的导入应解析为 ../lib/settings.mock.ts,而在您的应用程序中则解析为 ../lib/settings.ts

这也需要修改您的组件,使其根据Node.js 规范从以 # 符号为前缀的绝对路径导入,以确保与路径或包导入没有歧义。

// Dashboard.test.ts

- import { getDashboardLayout } from '../lib/settings';
+ import { getDashboardLayout } from '#lib/settings';

这可能看起来有些繁琐,但它的好处在于能清晰地向阅读文件的开发者传达该模块可能因运行时环境而异。事实上,我们通常推荐将此标准用于绝对导入,因为它对 mocking 有诸多益处(见下文)。

按故事 Mocking (Per-story mocking)

使用子路径导入,我们能够以基于标准的方法将整个 settings.ts 文件替换为一个新模块。但如果我们要为每个测试(或者在本例中,为每个 Storybook story)改变其实现,应该如何组织 settings.mock.ts 呢?

这是 mocking 任何模块的样板结构。由于我们完全控制代码,我们可以对其进行修改以适应任何特殊情况(例如,删除 Node 代码使其不在浏览器中运行,反之亦然)。

// lib/settings.mock.ts
import { fn } from '@storybook/test';
import * as actual from './settings'; // 👈 Import the actual implementation

// 👇 Re-export the actual implementation.
// This catch-all ensures that the exports of the mock file always contains
// all the exports of the original. It is up to the user to override
// individual exports below as appropriate.
export * from './settings';

// 👇 Export a mock function whose default implementation is the actual implementation.
// With a useful mockName, it displays nicely in Storybook's Actions addon
// for debugging.
export const getDashboardLayout = fn(actual.getDashboardLayout)
  .mockName('settings::getDashboardLayout');

现在,只要在 Storybook 中导入 #lib/settings,就会使用这个 mock 文件。它目前还没做太多事,只是包装了实际的实现——这才是关键。

现在让我们在 Storybook story 中使用它

// components/Dashboard.stories.ts

import type { Meta, StoryObj } from '@storybook/react';
import { expect } from '@storybook/test';

// 👇 You can use subpaths as an absolute import convention even
// for non-conditional paths
import { Dashboard } from '#components/Dashboard';

// 👇 Import the mock file explicitly, as that will make
// TypeScript understand that these exports are the mock functions
import { getDashboardLayout } from '#lib/settings.mock'

const meta = {
  component: Dashboard,
} satisfies Meta<typeof Dashboard>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
  beforeEach: () => {
    // 👇 Mock return an empty layout
    getDashboardLayout.mockReturnValue([]);
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // 👇 Expect the UI to prompt when the dashboard is empty
    await expect(canvas).toHaveTextContent('Configure your dashboard');
    // 👇 Assert directly on the mock function that it was called as expected
    expect(getDashboardLayout).toHaveBeenCalled();
  },
};

export const Row: Story = {
  beforeEach: () => {
    // 👇 Mock return a different, story-specific layout
    getDashboardLayout.mockReturnValue([ /* hard-coded "row" layout data */ ]);
  },
};

在 Storybook 中,使用 mock 函数 fn 意味着

  1. 我们可以使用 Storybook 新的 beforeEach hook 为每个故事修改其行为
  2. Actions 面板现在将记录每次函数被调用的情况
  3. 我们可以在 play 函数中断言这些调用
需要断言的不仅仅是文本?使用 Chromatic 的Visual Tests addon,快速测试您的组件在任何状态下的实际外观,以捕获跨多个浏览器和视口的 UI bug。

我们方法的优势

我们现在已经看到了基于 Subpath Imports package.json 标准的 Storybook 端到端模块 mocking 示例。与 Jest 和 Vitest 所采用的专有方法相比,此方法更明确、类型安全且基于标准。

明确

一些 mocking 框架背后的“魔法”可能使得理解 mock 如何以及何时被应用变得困难。例如,我们在上面看到,从 vi.mock 调用中引用外部定义的变量会导致 mocking 错误,即使它是有效的 JavaScript。

相比之下,由于所有 mock 都明确定义在 package.json 中,我们的解决方案提供了一种清晰且可预测的方式来理解模块在不同环境中的解析方式。这种透明性简化了调试,并使您的测试更具可预测性。

类型安全

Mocking 框架引入了开发者需要熟悉的约定、语法风格和特定 API。此外,这些解决方案通常缺乏对类型检查的支持。

通过使用现有的 package.json,我们的解决方案只需最少的设置。此外,它还能自然地与 TypeScript 集成,特别是 TypeScript 现在支持带有自动补全的 package.json 子路径导入(自 TypeScript 5.4,2024 年 3 月起)。

基于标准

最重要的是,由于 Storybook 的方法 100% 基于标准,这意味着您可以在任何工具链或环境中使用您的 mock。

这很有用,因为您可以学习这一标准,然后在任何地方重用这些知识,而不必学习每个工具的 mocking 细节。例如,vi.mock 的用法与 Jest 的 mocking 相似但不完全相同。

这也意味着您可以将多个工具结合使用。例如,用户通常会为其组件编写 story,然后使用我们的可移植故事 (Portable Stories) 功能在其他测试工具中重用这些 story。

此外,您可以在多种环境中使用这些 mock。例如,Storybook 的 mock 在 Node 中“免费”工作,因为它们是 Node 标准的一部分;但由于 Webpack 和 Vite 都实现了该标准,因此如果您使用其中一个构建工具,它们在浏览器中也能正常工作。

最后,由于我们与 ESM 标准保持一致,这确保了我们的解决方案向前兼容未来的 JS 变化。我们看好平台。我们相信这是模块 mocking 的未来,并且所有测试工具都应该采用它。

今天就试用

模块 mocking 已在 Storybook 8.1 中提供。在新的项目中试用

npx storybook@latest init

或升级现有项目

npx storybook@latest upgrade

要了解更多关于模块 mocking 的信息,请参阅Storybook 文档以获取更多示例和完整的 API。我们创建了一个完整的Next.js React Server Components (RSC) 应用演示,使用我们的模块 mocking 方法进行测试。我们计划在即将发布的博客文章中进一步记录这一点。

下一步是什么

Storybook 的模块 mocking 功能已完善,可随时使用。我们正在考虑以下增强功能:

  1. 一个 CLI 工具,用于自动为给定模块生成 mock 样板代码
  2. 支持从 UI 中可视化/编辑 mock 数据

除了模块 mocking,我们还在致力于众多测试改进。例如,我们构建了一种在浏览器中单元测试 React Server Components 的新颖方法。我们正在努力使 Storybook 的测试结构更接近 Jest/Vitest 中受 Jasmine 启发的结构。

要了解我们正在考虑和积极开展的项目概览,请查看Storybook 的路线图

加入 Storybook 邮件列表

获取最新新闻、更新和发布信息

7,180开发者及更多

我们正在招聘!

加入 Storybook 和 Chromatic 团队。构建被数十万开发者在生产中使用的工具。远程优先。

查看职位

热门文章

交互式故事生成

无需离开浏览器,几秒钟即可创建您的第一个故事!
loading
Valentin Palkovic

可视化测试:UI 开发中最伟大的技巧

减少维护,获得更多信心
loading
Michael Shilman

Storybook 8.1

一个更高效、更有组织、更可预测的 Storybook
loading
Michael Shilman
加入社区
7,180开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测数据
社区插件参与贡献博客
案例展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI