单元测试中的 Story
团队使用各种不同的工具来测试各种 UI 特性。每种工具都需要你反复设置相同的组件状态。这会带来维护上的麻烦。理想情况下,你应该以相似的方式设置你的测试,并在不同工具之间重复使用。
Storybook 使你能够在一个 *.stories.js|ts 文件中隔离组件并捕获其用例。Stories 是标准的 JavaScript 模块,与整个 JavaScript 生态系统兼容。
Stories 是 UI 测试的一个实际起点。将 stories 导入到 Jest、Testing Library、Vitest 和 Playwright 等工具中,可以节省时间和维护工作。
使用 Testing Library 编写测试
Testing Library 是一套用于基于浏览器的组件测试的辅助库。借助 Component Story Format,你的 stories 可以与 Testing Library 重复使用。每个命名的导出(story)都可以在你的测试设置中渲染。例如,如果你正在处理一个登录组件并想测试无效凭据场景,你可以这样编写你的测试
Storybook 提供了一个 composeStories 工具,它可以帮助你将测试文件中的 stories 转换为可渲染的元素,以便在 JSDOM 的 Node 测试中使用。它还允许你应用你在项目中启用的其他 Storybook 功能(例如,decorators、args)到你的测试中,使你可以在你选择的测试环境中(例如,Jest、Vitest)重复使用你的 stories,确保你的测试始终与你的 stories 同步,而无需重写它们。这就是我们在 Storybook 中所说的 portable stories。
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();
});你必须 配置你的测试环境以使用 portable stories,以确保你的 stories 与你的 Storybook 配置的所有方面(例如,decorators)一起组合。
一旦测试运行,它就会加载 story 并进行渲染。然后,Testing Library 会模拟用户的行为并检查组件状态是否已更新。
覆盖 story 属性
默认情况下,setProjectAnnotations 函数会将你在 Storybook 实例中定义的任何全局配置(即 preview.js|ts 文件中的参数、decorators)注入到你现有的测试中。然而,这可能会对不打算使用这些全局配置的测试产生意想不到的副作用。例如,你可能希望始终在特定区域(通过 globalTypes)测试一个 story,或者将一个 story 配置为应用特定的 decorators 或 parameters。
为避免这种情况,你可以通过扩展 composeStory 或 composeStories 函数来覆盖全局配置,从而提供特定于测试的配置。例如
// 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 上。但是,如果你依赖此方法,我们建议你将 story 元数据(即 default export)提供给 composeStory 函数。这确保了你的测试可以准确地确定关于 story 的正确信息。例如
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 合并到一个测试中
如果你打算在一个测试中测试多个 stories,请使用 composeStories 函数。它将处理你指定的每一个组件 story,包括你定义的任何 args 或 decorators。例如
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 2 和 Angular 等其他框架提供了社区驱动的插件。但是,这些插件仍然不支持最新的稳定版 Storybook。如果你有兴趣提供帮助,我们建议你使用默认的沟通渠道(GitHub 和 Discord 服务器)联系维护者。
Args 未传递到测试中
由 composeStories 或 composeStory 返回的组件不仅可以作为 React 组件进行渲染,还可以携带来自 story、meta 和全局配置的组合属性。这意味着,如果你想访问 args 或 parameters,例如,你可以这样做
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);
});Next.js Vite 找不到模块
如果你看到类似 Cannot find module 'sb-original/image-context' 的错误消息,请确保你已包含 storybookNextJsPlugin。
import { defineConfig } from 'vite';
import { storybookNextJsPlugin } from '@storybook/nextjs-vite/vite-plugin';
export default defineConfig({
// only necessary when not using @storybook/addon-vitest, otherwise the plugin is loaded automatically
plugins: [storybookNextJsPlugin()],
});更多测试资源
