問題
您正在使用 Storybook 來開發您的元件,並使用 Jasmine 測試框架或 Angular 測試庫(很可能搭配 Karma 測試執行器)為它們編寫測試。在您的 Storybook Story 中,您已經定義了元件的各種情境。您還設定了必要的裝飾器(主題、路由、狀態管理等)以使其全部正確渲染。當您編寫測試時,您也會最終定義元件的各種情境,以及設定必要的裝飾器。由於重複做相同的事情,您會覺得花費了太多精力,使得編寫和維護 Story/測試變得不那麼有趣,更像是一種負擔。
解決方案
@marklb/storybook-testing-angular
是一個在 Angular 測試中重複使用 Storybook Story 的解決方案。藉由在您的測試中重複使用您的 Story,您就有一個準備好測試的元件情境目錄。來自您的 args 和 裝飾器,以及 Story 及其 meta,還有 全域裝飾器,都將由此函式庫組合,並以簡單元件的形式返回給您。這樣,在您的單元測試中,您只需選擇要渲染哪個 Story,所有必要的設定都將為您完成。這是提高編寫測試和編寫 Storybook Story 之間共享性及維護性的缺失環節。
安裝
此函式庫應作為您專案的 devDependencies
之一安裝
透過 npm
設定
Storybook 6 和元件 Story 格式
此函式庫要求您使用 Storybook 版本 6、元件 Story 格式 (CSF) 和 提升的 CSF 註解,這是自 Storybook 6 以來建議的 Story 編寫方式。
基本上,如果您使用 Storybook 6 且您的 Story 看起來類似這樣,您就可以開始使用了!
// 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,
};
全域設定
這是一個可選步驟。如果您沒有全域裝飾器,則無需執行此步驟。但是,如果您有的話,這是應用您的全域裝飾器所必需的步驟。
如果您有全域裝飾器/參數等,並且希望在測試時將它們應用於您的 Story,則首先需要進行設定。您可以透過新增到測試 設定檔案 來完成此操作
// 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
將處理您指定的元件中的所有 Story,將所有 Story 中的 args/裝飾器組合起來,並傳回包含組合 Story 的物件。
如果您使用組合的 Story (例如 PrimaryButton),元件將使用 Story 中傳遞的 args 進行渲染。但是,您可以自由地在元件之上傳遞任何屬性,並且這些屬性將覆蓋 Story 的 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
如果您希望將其應用於單個 Story 而不是所有 Story,則可以使用 composeStory
。您還需要傳遞 meta(預設匯出)。
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();
});
});
重複使用 Story 屬性
由 composeStories
或 composeStory
傳回的元件不僅可以作為 Angular 元件渲染,而且還具有 Story、meta 和全域設定的組合屬性。這表示,如果您想存取 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 可能會將它們視為可為 null 的屬性並顯示錯誤。如果您確定它們存在(例如,Story 中設定的特定 arg),則可以使用非 null 斷言運算子告訴 typescript 一切正常
// ERROR: Object is possibly 'undefined'
Primary.args.children;
// SUCCESS: 🎉
Primary.args!.children;
Typescript
@marklb/storybook-testing-angular
已準備好使用 typescript,並提供自動完成功能,以便輕鬆偵測元件的所有 Story
它還會提供元件的 props,就像您在測試中直接使用它們時通常預期的一樣
僅當專案在其 tsconfig.json
檔案中將 strict
或 strictBindApplyCall
模式設定為 true
時,才有可能進行類型推斷。您還需要 4.0.0 以上的 TypeScript 版本。如果您沒有正確的類型推斷,這可能是原因。
// tsconfig.json
{
"compilerOptions": {
// ...
"strict": true, // You need either this option
"strictBindCallApply": true // or this option
// ...
}
// ...
}
免責聲明
為了自動提取類型,您的 Story 必須輸入類型。請參閱範例
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',
};