参加直播:美国东部时间周四上午 11 点,Storybook 9 发布及 AMA
文档
Storybook 文档

单元测试中的 Story

团队使用不同的工具测试各种 UI 特性。每种工具都需要你一遍又一遍地复制相同的组件状态。这会带来维护上的麻烦。理想情况下,你应该以类似的方式设置测试并在不同工具之间重用。

Storybook 使你能够隔离组件,并在 *.stories.js|ts 文件中捕获其用例。Story 是标准的 JavaScript 模块,与整个 JavaScript 生态系统兼容。

Story 是 UI 测试的实用起点。将 Story 导入到 JestTesting LibraryVitestPlaywright 等工具中,可以节省时间和维护工作。

使用 Testing Library 编写测试

Testing Library 是一套用于基于浏览器的组件测试的辅助库。使用组件 Story 格式,你的 Story 可以与 Testing Library 重用。每个命名导出(story)都可以在你的测试设置中渲染。例如,如果你正在开发一个登录组件并想测试无效凭据场景,你可以这样编写测试:

Storybook 提供了一个名为 composeStories 的工具函数,它可以帮助将测试文件中的 Story 转换为可渲染的元素,以便在你的 Node 测试中配合 JSDOM 重用。它还允许你将项目中启用的其他 Storybook 特性(例如 DecoratorArgs)应用到测试中,使你能够在所选的测试环境中(例如 JestVitest)重用你的 Story,确保你的测试始终与 Story 保持同步,而无需重新编写。这就是我们在 Storybook 中所说的可移植 Story。

Form.test.ts|tsx
import { fireEvent, render, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './LoginForm.stories'; // 👈 Our stories imported here.
 
const { InvalidForm } = composeStories(stories);
 
test('Checks if the form is valid', async () => {
  // Renders the composed story
  await InvalidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).toBeInTheDocument();
});

必须配置测试环境以使用可移植 Story,确保你的 Story 与 Storybook 配置的所有方面(例如 Decorator)一起组合。

测试运行后,它会加载 Story 并渲染它。然后,Testing Library 模拟用户的行为并检查组件状态是否已更新。

覆盖 Story 属性

默认情况下,setProjectAnnotations 函数会将你在 Storybook 实例中定义的任何全局配置(即 preview.js|ts 文件中的参数、Decorator)注入到现有测试中。然而,这可能会对不打算使用这些全局配置的测试造成意外的副作用。例如,你可能想始终在一个特定的区域设置(通过 globalTypes)中测试一个 Story,或者配置一个 Story 以应用特定的 decoratorsparameters

为了避免这种情况,你可以通过扩展 composeStorycomposeStories 函数来覆盖全局配置,以提供测试专用的配置。例如:

Form.test.js|ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './LoginForm.stories';
 
const { ValidForm } = composeStories(stories, {
  decorators: [
    // Decorators defined here will be added to all composed stories from this function
  ],
  globalTypes: {
    // Override globals for all composed stories from this function
  },
  parameters: {
    // Override parameters for all composed stories from this function
  },
});

在单个 Story 上运行测试

你可以使用 composeStory 函数让你的测试在单个 Story 上运行。但是,如果你依赖这种方法,我们建议你向 composeStory 函数提供 Story 元数据(即默认导出)。这确保你的测试可以准确确定关于 Story 的正确信息。例如:

Form.test.ts|tsx
import { fireEvent, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStory } from '@storybook/your-framework';
 
import Meta, { ValidForm as ValidFormStory } from './LoginForm.stories';
 
const ValidForm = composeStory(ValidFormStory, Meta);
 
test('Validates form', async () => {
  await ValidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).not.toBeInTheDocument();
});

将 Story 组合到单个测试中

如果你打算在单个测试中测试多个 Story,请使用 composeStories 函数。它将处理你指定的每个组件 Story,包括你定义的任何 argsdecorators。例如:

Form.test.ts|tsx
import { fireEvent, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as FormStories from './LoginForm.stories';
 
const { InvalidForm, ValidForm } = composeStories(FormStories);
 
test('Tests invalid form state', async () => {
  await InvalidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).toBeInTheDocument();
});
 
test('Tests filled form', async () => {
  await ValidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).not.toBeInTheDocument();
});

故障排除

在其他框架中运行测试

Storybook 为其他框架(如 Vue 2Angular)提供了社区主导的插件。然而,这些插件仍然缺少对最新稳定版 Storybook 的支持。如果你有兴趣提供帮助,我们建议你使用默认的沟通渠道(GitHub 和 Discord 服务器)联系维护者。

Args 未传递给测试

composeStoriescomposeStory 返回的组件不仅可以作为 React 组件渲染,还带有 Story、meta 和全局配置的组合属性。这意味着,例如,如果你想访问 args 或 parameters,你可以这样做:

Button.test.ts|tsx
import { render, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './Button.stories';
 
const { Primary } = composeStories(stories);
 
test('reuses args from composed story', () => {
  render(<Primary />);
 
  const buttonElement = screen.getByRole('button');
  // Testing against values coming from the story itself! No need for duplication
  expect(buttonElement.textContent).toEqual(Primary.args.label);
});

更多测试资源