问题
您正在使用 Storybook 来管理您的组件,并使用 Jasmine 测试框架 或 Angular 测试库(很可能使用 Karma 测试运行器)为它们编写测试。在您的 Storybook 故事中,您已经定义了组件的场景。您还设置了必要的装饰器(主题、路由、状态管理等)以确保它们都能正确渲染。在编写测试时,您最终也会定义组件的场景,并设置必要的装饰器。重复做同样的事情让您感觉付出了太多精力,使得编写和维护故事/测试变得不再有趣,更像是一种负担。
解决方案
@marklb/storybook-testing-angular
是一个解决方案,允许您在 Angular 测试中重用您的 Storybook 故事。通过在测试中重用故事,您就可以拥有一个可供测试的组件场景目录。来自您的故事及其元数据(meta)中的所有args和装饰器(decorators),以及全局装饰器,都将由这个库组合起来,并在一个简单的组件中返回给您。这样,在您的单元测试中,您只需选择要渲染的故事,所有必要的设置都将为您完成。这是填补空白的关键,可以更好地在编写测试和编写 Storybook 故事之间实现共享和维护。
安装
这个库应该作为项目的 devDependencies
之一进行安装
通过 npm
设置
Storybook 6 和组件故事格式 (CSF)
此库要求您使用 Storybook 版本 6、组件故事格式 (CSF) 和 提升的 CSF 注解 (hoisted CSF annotations),这是自 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
将处理您指定组件中的所有故事,组合所有故事的 args/decorators,并返回一个包含组合后故事的对象。
如果您使用组合后的故事(例如 PrimaryButton),组件将使用故事中传递的 args 进行渲染。但是,您可以自由地在该组件之上传递任何 props,这些 props 将覆盖故事 args 中传递的默认值。
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
。您还需要传入元数据(default export)。
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();
});
});
重用故事属性
由 composeStories
或 composeStory
返回的组件不仅可以作为 Angular 组件进行渲染,还带有故事、元数据和全局配置的组合属性。这意味着,例如,如果您想访问 args
或 parameters
,您可以这样做:
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 可能会将它们视为可空属性并报错。如果您确定它们存在(例如故事中设置的某个 arg),您可以使用非空断言运算符来告诉 typescript 一切正常。
// ERROR: Object is possibly 'undefined'
Primary.args.children;
// SUCCESS: 🎉
Primary.args!.children;
Typescript
@marklb/storybook-testing-angular
已准备好支持 typescript,并提供自动补全功能,以便轻松检测组件的所有故事。
它也提供组件的 props,就像您直接在测试中使用它们时通常期望的那样。
类型推断仅在那些在其 tsconfig.json
文件中将 strict
或 strictBindApplyCall
模式设置为 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',
};