12k
All articles

在 Storybook 中测试 API 驱动的组件

在 Storybook 10 中使用 MSW 测试 API 驱动组件的加载、错误、空状态和成功状态,并将故事转为组件测试。

OpenReplay Team
OpenReplay Team
在 Storybook 中测试 API 驱动的组件

在 Storybook 中,发起数据请求的组件会一直停留在”Loading…”状态或直接抛出错误,原因是没有后端服务来响应其请求——解决方案是通过 Mock Service Worker 在网络层拦截请求,而不是对 hook 进行打桩(stub)。

如果你曾经看到某个组件在 Storybook 中一直显示”Loading…”,控制台却没有任何错误信息,原因就在于此:组件挂载时发出的请求没有服务器来响应。只需配置一次 mock,此后编写的每个 story 都能自动获得同样的处理。

本文将介绍如何在 Storybook 10 上配置 msw-storybook-addon(需要 MSW 2.x),然后构建一个包含四个 story 的 UserList 组件(成功、加载中、错误和空数据),最后通过自动化交互测试来验证这些 mock 状态。

核心要点

  • 在网络层进行 mock,这样同一套 MSW handler 可以在 Storybook、基于 Node 的单元测试以及 Chromatic 视觉回归测试中无缝复用。
  • 在 MSW v2 中,成功响应的 handler 写法为 http.get(url, () => HttpResponse.json(data))rest.get 以及 (req, res, ctx) => res(ctx.json()) 的 resolver 签名已被移除。
  • 使用 await delay('infinite') 模拟永久加载状态,使用 HttpResponse.json(null, { status: 500 }) 模拟错误,使用 HttpResponse.json([]) 模拟空数据返回。
  • 只需在 .storybook/previewloaders 数组中添加 mswLoader,并在 main config 中配置 staticDirs 来托管 worker 文件,即可完成一次性全局配置。
  • 为每个 mock 状态配置 play 函数,以便自动断言该状态。带有 play 函数的 story 即成为一个组件测试。

为什么数据请求组件在 Storybook 中会出现问题?

Storybook 以隔离方式渲染组件,没有应用外壳,也没有服务器。一个在挂载时调用 fetchuseQuery 或 Apollo 的”应用组件”会发出请求,但没有任何东西来响应,因此它要么永远停留在加载分支,要么在 Promise reject 时抛出错误。直觉上的做法是对 hook 打桩:将 useQuery 替换为返回固定数据的 mock。但这种做法并不可取。对 hook 打桩会将 story 与特定的数据获取库以及组件的内部结构耦合在一起,一旦你需要在 Node 测试中运行相同的场景,这个 stub 就毫无用处。

正确的做法是在网络层进行 mock。MSW 注册一个 Service Worker,在浏览器中拦截出站请求并返回你定义的响应,因此组件的真实数据请求代码路径保持不变。同样的 handler 在 Node 环境下通过 setupServer 运行,这正是它们能够在 Storybook、Vitest 和 CI 之间移植的原因。场景只需编写一次,在组件运行的任何环境中都能正常工作。

如何配置 Storybook 的 mock API 技术栈?

安装两个依赖包,生成 Service Worker,注册 loader,并将 Storybook 指向 worker 文件。这是一次性配置,遵循 Storybook 关于 mock 网络请求的官方指南

npm install msw msw-storybook-addon --save-dev
npx msw init public/

npx msw init public/ 会将 mockServiceWorker.js 写入你的静态资源目录。通过在 .storybook/preview.tsloaders 中添加 mswLoader,将 addon 全局注册。Loader 在 story 渲染之前运行,这也是该 addon 选择使用 loader 而非 decorator 的原因:

import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';

initialize();

const preview: Preview = {
  loaders: [mswLoader],
};

export default preview;

然后在 .storybook/main.tsstaticDirs 中列出你的 public 目录,以托管生成的 worker 文件。Storybook 10 中已不再支持旧的 start-storybook -s public 标志:

import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  framework: '@storybook/react-vite',
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  staticDirs: ['../public'],
};

export default config;

成功状态的 Story

以下是被测组件——UserList,它获取一个数组并渲染四种 UI 分支之一。注意其中使用了语义化的 role="status"role="alert",测试中将基于这些属性进行查询。

// UserList.tsx
import { useEffect, useState } from 'react';

type User = { id: number; name: string };
const endpoint = 'https://api.example.com/users';

export function UserList() {
  const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    fetch(endpoint)
      .then((res) => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setStatus('success');
      })
      .catch(() => setStatus('error'));
  }, []);

  if (status === 'loading') return <p role="status">Loading…</p>;
  if (status === 'error') return <p role="alert">Something went wrong.</p>;
  if (users.length === 0) return <p>No users yet.</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

通过 parameters.msw.handlers 为每个 story 单独配置 handler。在 MSW v2 中,http.get 取代了 rest.getHttpResponse 类取代了 ctx 工具函数:

// UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { http, HttpResponse, delay } from 'msw';
import { UserList } from './UserList';

const endpoint = 'https://api.example.com/users';

const meta = { component: UserList } satisfies Meta<typeof UserList>;
export default meta;
type Story = StoryObj<typeof meta>;

export const Success: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get(endpoint, () =>
          HttpResponse.json([
            { id: 1, name: 'Ada Lovelace' },
            { id: 2, name: 'Alan Turing' },
          ]),
        ),
      ],
    },
  },
};

模拟每一种状态

大多数教程止步于正常路径,而这恰恰是最不值得关注的 story。网络层 mock 的真正价值在于:只需替换一个 handler,就能产生组件可能进入的每一种状态。对 API 驱动 UI 的会话回放(session replay)经常暴露出开发者从未在 Storybook 中建模过的状态:一个因请求挂起而永不消失的 spinner,或者一个因返回空数据而导致布局错乱的空状态页面。Storybook 配合 MSW,正是在这些问题上线之前对其进行编写和断言的最佳场所。

状态Handler验证内容
加载中响应前执行 await delay('infinite')待处理 UI 正常渲染且不会闪烁
错误HttpResponse.json(null, { status: 500 })错误分支能正确处理 5xx 响应
空数据HttpResponse.json([])零结果的布局经过设计,而非展示为异常

delay 函数接受 'infinite' 模式,使请求永久处于 pending 状态,这是将组件冻结在加载分支的可靠方式。从 msw 中导入 delay,并在异步 resolver 中 await 它:

export const Loading: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get(endpoint, async () => {
          await delay('infinite');
          return HttpResponse.json([]);
        }),
      ],
    },
  },
};

export const Error: Story = {
  parameters: {
    msw: { handlers: [http.get(endpoint, () => HttpResponse.json(null, { status: 500 }))] },
  },
};

export const Empty: Story = {
  parameters: {
    msw: { handlers: [http.get(endpoint, () => HttpResponse.json([]))] },
  },
};

MSW 以相同的方式 mock GraphQL(graphql.query('AllUsers', () => HttpResponse.json({ data }))),因此该模式同样适用于 Apollo、urql 和 React Query,无需任何改动。

从查看到测试

一个只用来查看的 mock story 仅仅是文档;添加 play 函数后,它就变成了可自动化执行的组件测试。从 storybook/test(当前模块,已取代 Storybook 8 的 @storybook/test)中导入 expect,并对渲染结果进行断言:

import { expect } from 'storybook/test';

// 成功状态:
play: async ({ canvas }) => {
  await expect(await canvas.findByText('Ada Lovelace')).toBeInTheDocument();
},

// 加载中状态:
play: async ({ canvas }) => {
  await expect(canvas.getByRole('status')).toBeInTheDocument();
},

对于使用无限延迟的 Loading story,只需断言 spinner 存在即可,不要等待请求完成,因为请求按设计永远处于 pending 状态。这些 story 通过 Vitest addon 运行,该插件在 Playwright 的 Chromium 浏览器中将其作为组件测试执行,可从 Storybook UI、终端或 CI 中触发。由于 MSW handler 与运行环境无关,同样的成功/错误/空数据 handler 可以通过 setupServer 支撑独立的 Vitest 测试,Chromatic 也会对每个 story 进行快照以实现视觉回归测试。

在网络层进行 mock,对全部四种状态建模,并为每种状态附加 play 函数:这样一来,一个 story 文件夹就变成了一套实时测试套件,能够在”永久加载”和”空状态布局异常”等 bug 进入生产环境之前将其捕获。现在就从为某个现有应用组件添加加载中、错误和空数据 story 开始吧。你在这里编写的 handler,正是 Vitest 测试将要复用的那些。

常见问题

安装 msw-storybook-addon 后,为什么组件在 Storybook 中仍然卡在“Loading…”?

组件卡住的原因通常是:没有 MSW handler 匹配其请求,或者 addon 的 loader 未正确配置。请确认以下几点:已在 .storybook/preview 的 loaders 数组中添加 mswLoader 并调用了 initialize();已通过 npx msw init 生成 public 目录并在 staticDirs 中列出;parameters.msw.handlers 中的 handler 与请求的 URL 和方法完全匹配。URL 不匹配会导致请求无人处理,组件永久处于 pending 状态。

在网络层用 MSW 进行 mock 与对 fetch hook 打桩有什么区别?

使用 MSW 在网络层进行 mock 会拦截真实的出站请求并返回响应,因此组件的真实数据请求代码路径保持不变,同样的 handler 可以在浏览器、Node 环境(通过 setupServer)以及 Chromatic 中通用。对 hook 打桩则是用固定数据替换 useQuery 或 fetch,这会将 story 与特定的数据获取库及组件内部结构耦合,且无法在 Node 测试中复用。

msw-storybook-addon 是否兼容 MSW v1 的 handler 写法,如 rest.get 和 res(ctx.json())?

不兼容。自 2.0.0 版本起,该 addon 要求 MSW 2.0.0 或更高版本,而 MSW v2 已移除 rest 命名空间和 res(ctx.json()) 的 resolver 签名。请使用 http.get 和 HttpResponse 类重写 handler,例如 http.get(url, () => HttpResponse.json(data))。MSW v1 的代码无法在当前 addon 版本中运行,必须参照 MSW 官方的 1.x 到 2.x 迁移指南进行迁移。

为什么使用无限延迟的加载状态 story 在作为测试运行时会挂起?

使用 await delay('infinite') 的 story 按设计会使请求永久处于 pending 状态,因此等待 UI 解析完成的 play 函数永远不会结束。应只断言待处理 UI 是否存在,例如验证带有 role status 的 spinner 已渲染,而不是等待请求完成。如果 Node 或 Vitest 测试需要正常结束,可在该场景中使用有限延迟,如 delay(1000),而非无限模式。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.