允許您在單元測試中重複使用您的 Story 的測試工具

在 Github 上檢視

問題

您正在使用 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 屬性

composeStoriescomposeStory 傳回的元件不僅可以作為 Angular 元件渲染,而且還具有 Story、meta 和全域設定的組合屬性。這表示,如果您想存取 argsparameters(例如),您可以這麼做

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

component autocompletion

它還會提供元件的 props,就像您在測試中直接使用它們時通常預期的一樣

props autocompletion

僅當專案在其 tsconfig.json 檔案中將 strictstrictBindApplyCall 模式設定為 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',
};

授權

MIT