返回博客

类型安全的 Storybook 模块模拟

一种新的、基于标准的模拟方法

loading
Jeppe Reinhold
@DrReinhold
loading
Kasper Peulen
@KasperPeulen

一致性对于隔离地开发和测试 UI 至关重要。

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

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

但是,如果您的组件依赖于另一个来源,例如浏览器 API,如读取用户的主题偏好、localStorage 中的数据或 cookies 怎么办?或者,如果您的组件根据当前日期或时间表现不同怎么办?或者,您的组件可能使用了像 Next.js 的 next/router 这样的元框架 API 吗?

在 Storybook 中,模拟这些类型的输入在历史上一直很困难。而这正是我们今天在 Storybook 8.1 中通过模块模拟解决的问题!我们的方法简单、类型安全且基于标准。它偏爱显式性和调试清晰度,而不是不透明/专有的模块 API。并且我们有很好的同伴:Epic Stack 创建者 Kent C. Dodd 建议使用类似的方法进行绝对导入,而 React Server Component 架构师 Seb Markbåge 直接启发了 Storybook 模拟。

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

什么是模块模拟?

模块模拟是一种技术,您可以使用一致的、独立的替代方案来替换组件直接或间接导入的模块。在单元测试中,这可以帮助在可重现的状态下测试代码。在 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 组件,我们想要创建一组不同布局的示例,这些示例可以练习关键状态。为了简单起见,并且在不失一般性的前提下,我们只关注读取布局的部分。

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

现有方法:专有 API

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

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

或者,它们提供命令式 API 来在您的测试文件中声明模拟

// 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,但在底层,此代码实际上会触发一个复杂的、有点神奇的文件转换,以将导入替换为其模拟。因此,代码的微小更改可能会以令人困惑的方式破坏模拟。例如,以下变体失败

// 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!!!
});

但我们的目标不是抨击这些优秀的工具中的任何一个。相反,我们希望探索如何使用一种新的、基于标准的方法来更好地进行模拟。

我们的方法:子路径导入

Storybook 中的模块模拟利用了 子路径导入 标准,该标准可通过 package.jsonimports 字段进行配置——package.json 是任何 JS 项目的核心——作为在整个项目中导入模拟的管道。

对于我们的目的,这种方法的超能力之一是,就像 package.json exports 一样,package.json imports 可以是有条件的,根据运行时环境改变导入路径。这意味着您可以定制您的 package.json 以在 Storybook 中导入模拟模块,同时在其他地方导入真实模块!

子路径导入首先在 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"
    ]
  }
}

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

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

// Dashboard.test.ts

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

这可能看起来很麻烦,但它的好处是清楚地告知读取文件的开发人员,模块可能因运行时而异。事实上,我们建议将此标准用于一般情况下的绝对导入,因为它对于模拟非常有用(见下文)。

按故事模拟

使用子路径导入,我们能够使用基于标准的方法将整个 settings.ts 文件替换为新模块。但是,如果我们想要为每个测试(或在我们的例子中,Storybook 故事)改变 settings.mock.ts 的实现,我们应该如何构建它?

这是一个用于模拟任何模块的样板结构。因为我们完全控制代码,所以我们可以修改它以适应任何特殊情况(例如,删除 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');

现在,每当导入 #lib/settings 时,此模拟文件都将在 Storybook 中使用。它没有做太多事情,除了包装实际的实现——这是重要的部分。

现在让我们在 Storybook 故事中使用它

// 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 中,使用模拟函数 fn 意味着

  1. 我们可以使用 Storybook 的新 beforeEach 钩子来修改其在每个故事中的行为
  2. 现在,每当调用该函数时,“操作”面板都会记录
  3. 我们可以在 play 函数中断言调用
需要断言的不仅仅是文本?使用来自 Chromatic 的 可视化测试插件,快速测试您的组件在任何状态下的实际外观,以捕获跨多个浏览器和视口的 UI 错误。

我们方法的优势

我们现在已经看到了一个基于子路径导入 package.json 标准的 Storybook 中的端到端模块模拟示例。与 Jest 和 Vitest 采用的专有方法相比,这种方法是显式的、类型安全的且基于标准的。

显式

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

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

类型安全

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

通过使用您现有的 package.json,我们的解决方案只需要最少的设置。此外,它可以自然地与 TypeScript 集成,尤其是在 TypeScript 现在支持带有自动完成功能的 package.json 子路径导入之后(截至 TypeScript 5.4,2024 年 3 月)。

基于标准

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

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

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

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

最后,由于我们与 ESM 标准保持一致,因此确保了我们的解决方案与未来的 JS 更改向前兼容。我们正在押注平台。我们相信这是模块模拟的未来,并且每个测试工具都应该采用它。

立即尝试

模块模拟在 Storybook 8.1 中可用。在新项目中尝试一下

npx storybook@latest init

或升级现有项目

npx storybook@latest upgrade

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

下一步是什么

Storybook 的模块模拟功能已完整且可以使用。我们正在考虑以下增强功能

  1. 一个 CLI 实用程序,用于为给定模块自动生成模拟样板
  2. 支持从 UI 可视化/编辑模拟数据

除了模块模拟之外,我们还在进行许多测试改进。例如,我们构建了一种在浏览器中单元测试 React Server Components 的新方法。并且我们正在努力使 Storybook 的测试更接近 Jest/Vitest 的 Jasmine 风格结构。

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

加入 Storybook 邮件列表

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

6,730位开发者及更多

我们正在招聘!

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

查看职位

热门文章

交互式故事生成

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

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

以更少的维护获得更多信心
loading
Michael Shilman

Storybook 8.1

更高效、更有组织、更可预测的 Storybook
loading
Michael Shilman
加入社区
6,730位开发者及更多
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与其中博客
案例探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI