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

在 Github 上檢視

問題

您正在使用 Storybook 來開發元件,並使用 Jasmine 測試框架Angular 測試庫(很可能搭配 Karma 測試執行器)來為它們編寫測試。在您的 Storybook Story 中,您已經定義了元件的場景。您還設定了必要的裝飾器(主題、路由、狀態管理等),以使其全部正確呈現。當您在編寫測試時,最終也會定義元件的場景,以及設定必要的裝飾器。重複做相同的事情,您會感覺自己花費太多力氣,使得編寫和維護 Story/測試變得不那麼有趣,反而更像是一種負擔。

解決方案

@storybook/testing-angular 是一個在 Angular 測試中重複使用 Storybook Story 的解決方案。透過在測試中重複使用您的 Story,您會有一個元件場景目錄,可以隨時進行測試。來自您的 story 及其 meta 的所有 argsdecorators,以及 全域裝飾器,都將由這個庫組成並以簡單的元件形式返回給您。這樣一來,在您的單元測試中,您只需選擇要呈現哪個 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 屬性

composeStoriescomposeStory 返回的元件不僅可以呈現為 Angular 元件,還帶有來自 Story、meta 和全域設定的組合屬性。這表示如果您想存取 argsparameters 等,您可以這樣做

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

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

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

授權

MIT

由以下人員製作
  • domyen
    domyen
  • kasperpeulen
    kasperpeulen
  • valentinpalkovic
    valentinpalkovic
  • jreinhold
    jreinhold
  • kylegach
    kylegach
  • ndelangen
    ndelangen
標籤