加入直播会议:周四美国东部时间上午 11 点,Storybook 9 发布及 AMA

允许您在单元测试中重用 Storybook stories 的测试工具

在 Github 上查看

问题

您正在使用 Storybook 来管理您的组件,并使用 Jasmine 测试框架Angular testing library 为它们编写测试,很可能使用的是 Karma 测试运行器。在您的 Storybook stories 中,您已经定义了组件的场景。您还设置了必要的装饰器(如主题、路由、状态管理等),以确保它们都能正确渲染。当您编写测试时,您也需要定义组件的场景,并设置必要的装饰器。重复做相同的事情让您觉得花费了太多精力,使得编写和维护 stories/测试变得不再有趣,更像是一种负担。

解决方案

@storybook/testing-angular 是一个在 Angular 测试中重用 Storybook stories 的解决方案。通过在测试中重用您的 stories,您拥有一个随时可供测试的组件场景目录。来自您的 story 及其 meta 的所有 argsdecorators,以及 全局装饰器,都将由此库进行组合,并以一个简单的组件形式返回给您。通过这种方式,在您的单元测试中,您只需选择想要渲染哪个 story,所有必要的设置都将为您完成。这是弥补编写测试与编写 Storybook stories 之间更好共享性和可维护性的缺失部分。

安装

此库应作为您项目的 devDependencies 之一进行安装

通过 npm

设置

Storybook 8 和 组件故事格式

此库要求您使用 Storybook 版本 8、组件故事格式 (CSF)提升的 CSF 注解 (hoisted CSF annotations),这是 Storybook 8 以来推荐的编写 stories 的方式。

本质上,如果您使用 Storybook 8 并且您的 stories 看起来类似于此,那么您就可以开始了!

// CSF: default export (meta) + named exports (stories)
export default {
  title: 'Example/Button',
  component: Button,
} as Meta;

const Primary: Story<ButtonComponent> = args => (args: ButtonComponent) => ({
  props: args,
}); // or with Template.bind({})
Primary.args = {
  primary: true,
};

全局配置

这是可选步骤。如果您没有 全局装饰器,则无需执行此步骤。但是,如果您有全局装饰器,这是应用它们所必需的步骤。

如果您有全局装饰器/参数等,并希望在测试 stories 时应用它们,您首先需要进行此设置。您可以通过将其添加到测试 设置文件 来完成。

// test.ts <-- this will run before the tests in karma.
import { setProjectAnnotations } from '@storybook/testing-angular';
import * as globalStorybookConfig from '../.storybook/preview'; // path of your preview.js file

setProjectAnnotations(globalStorybookConfig);

用法

composeStories

composeStories 将处理您指定组件的所有 stories,在其中组合 args/装饰器,并返回一个包含组合后 stories 的对象。

如果您使用组合后的 story(例如 PrimaryButton),组件将根据 story 中传递的 args 进行渲染。但是,您可以自由地在组件之上传递任何 props,这些 props 将覆盖 story 的 args 中传递的默认值。

import { render, screen } from '@testing-library/angular';
import {
  composeStories,
  createMountable,
} from '@storybook/testing-angular';
import * as stories from './button.stories'; // import all stories from the stories file
import Meta from './button.stories';

// Every component that is returned maps 1:1 with the stories, but they already contain all decorators from story level, meta level and global level.
const { Primary, Secondary } = composeStories(stories);

describe('button', () => {
  it('renders primary button with default args', async () => {
    const { component, applicationConfig } = createMountable(
      Primary({})
    );
    await render(component, { providers: applicationConfig.providers });
    const buttonElement = screen.getByText(
      /Text coming from args in stories file!/i
    );
    expect(buttonElement).not.toBeNull();
  });

  it('renders primary button with overriden props', async () => {
    const { component, applicationConfig } = createMountable(
      Primary({ label: 'Hello world' })
    ); // you can override props and they will get merged with values from the Story's args
    await render(component, { providers: applicationConfig.providers });
    const buttonElement = screen.getByText(/Hello world/i);
    expect(buttonElement).not.toBeNull();
  });
});

composeStory

如果您希望仅应用于单个 story 而不是所有 stories,则可以使用 composeStory。您还需要传递 meta(默认导出)。

import { render, screen } from '@testing-library/angular';
import {
  composeStory,
  createMountable,
} from '@storybook/testing-angular';
import Meta, { Primary as PrimaryStory } from './button.stories';

// Returns a component that already contain all decorators from story level, meta level and global level.
const Primary = composeStory(PrimaryStory, Meta);

describe('button', () => {
  it('onclick handler is called', async () => {
    const onClickSpy = jasmine.createSpy();
    const { component, applicationConfig } = createMountable(
      Primary({ onClick: onClickSpy })
    );
    await render(component, { provider: applicationConfig.provider });
    const buttonElement = screen.getByText(Primary.args?.label!);
    buttonElement.click();
    expect(onClickSpy).toHaveBeenCalled();
  });
});

重用 story 属性

composeStoriescomposeStory 返回的组件不仅可以作为 Angular 组件渲染,还包含了 story、meta 和全局配置的组合属性。这意味着,例如,如果您想访问 argsparameters,您可以这样做

import { render, screen } from '@testing-library/angular';
import {
  composeStory,
  createMountable,
} from '@storybook/testing-angular';
import * as stories from './button.stories';
import Meta from './button.stories';

const { Primary } = composeStories(stories);

describe('button', () => {
  it('reuses args from composed story', async () => {
    const { component, applicationConfig } = createMountable(Primary({}));
    await render(component, { providers: applicationConfig.providers });
    expect(screen.getByText(Primary.args?.label!)).not.toBeNull();
  });
});

如果您使用 Typescript:鉴于某些返回的属性并非必需,typescript 可能会将它们视为可空属性并报错。如果您确定它们存在(例如,在 story 中设置了某个 arg),则可以使用 非空断言运算符 (non-null assertion operator) 来告知 typescript 没问题。

// ERROR: Object is possibly 'undefined'
Primary.args.children;

// SUCCESS: 🎉
Primary.args!.children;

Typescript

@storybook/testing-angular 支持 typescript 并提供自动完成功能,可轻松检测您组件的所有 stories

component autocompletion

它还提供了组件的 props,就像您在测试中直接使用它们时通常期望的那样

props autocompletion

类型推断仅在 tsconfig.json 文件中将 strictstrictBindApplyCall 模式设置为 true 的项目中才可能实现。您还需要 TypeScript 版本高于 4.0.0。如果您没有正确的类型推断,这可能是原因。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "strict": true, // You need either this option
    "strictBindCallApply": true // or this option
    // ...
  }
  // ...
}

说明

为了类型能够被自动识别,您的 stories 必须是类型化的。请参阅示例

import { Story, Meta } from '@storybook/angular';

import { ButtonComponent } from './button.component';

export default {
  title: 'Components/Button',
  component: ButtonComponent,
} as Meta;

// Story<Props> is the key piece needed for typescript validation
const Template: Story<ButtonComponent> = (args: ButtonComponent) => ({
  props: args,
});

export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: 'Button',
};

许可证

MIT

贡献者
  • domyen
    domyen
  • kasperpeulen
    kasperpeulen
  • valentinpalkovic
    valentinpalkovic
  • jreinhold
    jreinhold
  • kylegach
    kylegach
  • ndelangen
    ndelangen
标签