問題
您正在使用 Storybook 來開發元件,並使用 Jasmine 測試框架或 Angular 測試庫(很可能搭配 Karma 測試執行器)來為它們編寫測試。在您的 Storybook Story 中,您已經定義了元件的場景。您還設定了必要的裝飾器(主題、路由、狀態管理等),以使其全部正確呈現。當您在編寫測試時,最終也會定義元件的場景,以及設定必要的裝飾器。重複做相同的事情,您會感覺自己花費太多力氣,使得編寫和維護 Story/測試變得不那麼有趣,反而更像是一種負擔。
解決方案
@storybook/testing-angular
是一個在 Angular 測試中重複使用 Storybook Story 的解決方案。透過在測試中重複使用您的 Story,您會有一個元件場景目錄,可以隨時進行測試。來自您的 story 及其 meta 的所有 args 和 decorators,以及 全域裝飾器,都將由這個庫組成並以簡單的元件形式返回給您。這樣一來,在您的單元測試中,您只需選擇要呈現哪個 Story,所有必要的設定都會為您完成。這是缺失的一塊拼圖,它能讓編寫測試和編寫 Storybook Story 之間有更好的共享性和維護性。
安裝
這個庫應該作為您專案的 devDependencies
之一安裝
透過 npm
設定
Storybook 8 和元件 Story 格式
這個庫要求您使用 Storybook 第 8 版、元件 Story 格式 (CSF) 和 提升的 CSF 註釋,這是自 Storybook 8 以來推薦的編寫 Story 的方式。
基本上,如果您使用 Storybook 8 且您的 Story 看起來與此類似,那就沒問題了!
// 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,
};
全域設定
這是一個可選步驟。如果您沒有 全域裝飾器,則無需執行此操作。但是,如果您有,這是應用全域裝飾器的必要步驟。
如果您有全域裝飾器/參數/等等,並且希望在測試 Story 時應用它們,您首先需要進行此設定。您可以在測試 設定檔中新增此設定
// 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
將處理您指定的元件中的所有 Story,組合它們中的 args/decorators,並返回一個包含組合 Story 的物件。
如果您使用組合的 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 而不是所有 Story,則可以使用 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 屬性
composeStories
或 composeStory
返回的元件不僅可以呈現為 Angular 元件,還帶有來自 Story、meta 和全域設定的組合屬性。這表示如果您想存取 args
或 parameters
等,您可以這樣做
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 可能會將它們視為可為 null 的屬性並顯示錯誤。如果您確定它們存在(例如在 Story 中設定的特定 arg),則可以使用非 null 斷言運算子來告知 TypeScript 一切都很好
// ERROR: Object is possibly 'undefined'
Primary.args.children;
// SUCCESS: 🎉
Primary.args!.children;
Typescript
@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: ButtonComponent) => ({
props: args,
});
export const Primary = Template.bind({});
Primary.args = {
primary: true,
label: 'Button',
};