允许您在单元测试中重用故事的测试工具

在 Github 上查看

问题

您正在使用 Storybook 用于组件,并使用 Jasmine 测试框架Angular 测试库 为它们编写测试,最有可能的是使用 Karma 测试运行器。在 Storybook 故事中,您已经定义了组件的场景。您还设置了必要的装饰器(主题、路由、状态管理等)以使它们全部正确呈现。在编写测试时,您最终也会定义组件的场景,并设置必要的装饰器。通过两次做同样的事情,您会觉得花费了太多精力,使得编写和维护故事/测试变得不再那么有趣,而更像是一种负担。

解决方案

@marklb/storybook-testing-angular 是一个在 Angular 测试中重用 Storybook 故事的解决方案。通过在测试中重用故事,您将拥有一个可供测试的组件场景目录。所有来自您的 参数装饰器 故事 及其 元数据,以及 全局装饰器,都将由该库进行组合并以简单组件的形式返回给您。这样,在单元测试中,您只需要选择要渲染的故事,所有必要的设置都将为您完成。这是允许在编写测试和编写 Storybook 故事之间更好地共享和维护的缺失部分。

安装

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

通过 npm

设置

Storybook 6 和组件故事格式

该库要求您使用 Storybook 版本 6、组件故事格式 (CSF)提升的 CSF 注释,这是 Storybook 6 自推出以来的推荐故事编写方式。

从本质上讲,如果您使用 Storybook 6,并且您的故事看起来像这样,您就可以开始使用了!

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

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

全局配置

这是一个可选步骤。如果您没有 全局装饰器,则无需执行此操作。但是,如果您有全局装饰器,那么这是在测试时将全局装饰器应用于故事的必要步骤。

如果您有全局装饰器/参数等,并且希望在测试时将它们应用于您的故事,则需要先进行此设置。您可以通过将它添加到测试 设置文件 中来实现。

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

setGlobalConfig(globalStorybookConfig);

用法

composeStories

composeStories 将处理您指定组件的所有故事,在所有故事中组合参数/装饰器,并返回一个包含组合故事的对象。

如果您使用组合的故事(例如 PrimaryButton),该组件将使用故事中传入的参数进行渲染。但是,您可以自由地在组件之上传递任何道具,这些道具将覆盖故事参数中传递的默认值。

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

// 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', () => {
    const { component, ngModule } = createMountableStoryComponent(
      Primary({}, {} as any)
    );
    await render(component, { imports: [ngModule] });
    const buttonElement = screen.getByText(
      /Text coming from args in stories file!/i
    );
    expect(buttonElement).not.toBeNull();
  });

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

composeStory

如果您希望将它应用于单个故事而不是所有故事,可以使用 composeStory。您需要传入元数据(默认导出)。

import { render, screen } from '@testing-library/angular';
import { composeStory } from '@marklb/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.createSpyObj('EventEmitter', ['emit']);
    const { component, ngModule } = createMountableStoryComponent(
      Primary({ onClick: onClickSpy }, {} as any)
    );
    await render(component, { imports: [ngModule] });
    const buttonElement = screen.getByText(Primary.args?.label!);
    buttonElement.click();
    expect(onClickSpy.emit).toHaveBeenCalled();
  });
});

重用故事属性

composeStoriescomposeStory 返回的组件不仅可以作为 Angular 组件进行渲染,还可以包含来自故事、元数据和全局配置的组合属性。这意味着,如果您想访问 参数参数,例如,您可以这样做

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

const { Primary } = composeStories(stories);

describe('button', () => {
  it('reuses args from composed story', async () => {
    const { component, ngModule } = createMountableStoryComponent(
      Primary({}, {} as any)
    );
    await render(component, { imports: [ngModule] });
    expect(screen.getByText(Primary.args?.label!)).not.toBeNull();
  });
});

**如果您使用的是 Typescript**:鉴于某些返回的属性不是必需的,Typescript 可能将其视为可空属性并出现错误。如果您确定它们存在(例如,在故事中设置的特定参数),可以使用 非空断言运算符 告诉 Typescript 一切正常

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

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

Typescript

@marklb/storybook-testing-angular 适用于 Typescript,并提供自动完成功能以轻松检测组件的所有故事

component autocompletion

它还提供组件的道具,就像您在测试中直接使用它们时所期望的那样

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
    // ...
  }
  // ...
}

免责声明

为了使类型能够自动获取,您的故事必须进行类型化。请查看示例

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: Button) => ({
  props: args,
});

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

许可证

MIT