🎨

TypeScript + Storybook CSF3.0の書き方とユニットテストへの応用

2022/01/13に公開

概要

Storybook6.4からデフォルトでCSF3の書き方が使えるようになったので、自分なりに調べたことをまとめてみました。TypeScript + Reactで説明します。

公式の記事はこちら
https://storybook.js.org/blog/component-story-format-3-0/

この記事の内容は以下のとおりです。

  1. CSF2からCSF3の変更点
  2. CSFの既知の問題と公式の対応状況
  3. インタラクティブストーリーの書き方
  4. CSF3で書いたストーリーをユニットテストに応用する方法

この記事の説明で使うコンポーネント

名前を入力してSubmitボタンを押すと、ボタンを押した時の入力テキストを下に表示するコンポーネントです。

components/SimpleForm.tsx
import { VFC, useState } from 'react'

export type Props = {
  title: string
}

const SimpleFrom: VFC<Props> = ({ title }) => {
  const [name, setName] = useState('')
  const [printName, setPrintName] = useState('')

  return (
    <>
      <h1>{title}</h1>
      <form
        onSubmit={(event) => {
          setPrintName(name)
          event.preventDefault()
        }}
      >
        <label>
          Name:
          <input
            type="text"
            name="name"
            value={name}
            onChange={(event) => {
              setName(event.target.value)
            }}
          />
        </label>
        <input type="submit" value="Submit" />
      </form>
      <div data-testid="print-name">入力した名前:{printName}</div>
    </>
  )
}

export default SimpleFrom

CSF2からCSF3の変更点

CSF2とCSF3の書き方はそれぞれ以下のようになっています。

CSF2のストーリーファイル

compenents/SimpleForm.stories.tsx
import { Story, Meta } from '@storybook/react/types-6-0'
import SimpleFrom, { Props } from './SimpleForm'

export default {
  title: 'Atoms/SimpleFrom',
  component: SimpleFrom
} as Meta<Props>

const Template: Story<Props> = (args) => <SimpleFrom {...args} />

export const Index = Template.bind({})
Index.args = {
  title: 'お名前フォーム'
}

CSF3のストーリーファイル

compenents/SimpleForm.stories.tsx
import { ComponentMeta, ComponentStoryObj } from '@storybook/react'
import SimpleForm from './SimpleForm'

export default { component: SimpleForm } as ComponentMeta<typeof SimpleForm>

export const Index: ComponentStoryObj<typeof SimpleForm> = {
  args: {
    title: 'お名前フォーム'
  }
}

変更点

titleが省略できる

ディレクトリ構造・ファイル名・ストーリー名からタイトルを自動生成できます。自動生成するタイトルにプレフィックスをつけるなどの設定もできるので詳しくは公式の記事を読んでください。

ストーリーが関数からオブジェクトになった

Templateを作成して、各ストーリーをbindする、という処理を書く必要がなくなりました。渡すものがなくデフォルトでいいなら空のオブジェクトを渡すだけで良いです。

export const Index: ComponentStoryObj<typeof SimpleForm> = {}
新しい型
  • ComponentMeta

変更点のうちこれだけはCSF3のものではなく、Storybook6.3で追加された型です。Metaの型引数が必須になったものです。コンポーネントのPropsの型をストーリーファイルにimportする必要をなくす目的があるらしいです。

https://github.com/storybookjs/Storybook/discussions/16650

  • ComponentStoryObj

こちらの型はCSF3のために追加された型です。ストーリーが関数からオブジェクトに変わったのでオブジェクト用の型です。

play関数が使えるようになった

これについては後述の「インタラクティブストーリーの書き方」のところで説明します。

CSFの既知の問題と公式の対応状況

Propsの型が全てオプショナルになってしまう

デフォルトで設定する値と各ストーリーで追加で渡す値、という2階層を実現するためにそのような仕様にしているらしいです。しかし、この仕様によって型チェックがゆるくなり、後からコンポーネントのPropsを変更してもストーリーファイルで型エラーが発生しないので、ストーリーが壊れていることに気づきにくくなります。

この問題は以下で議論され、解決策が提案されているので近いうちに改善されそうです。

https://github.com/storybookjs/storybook/issues/13747

ストーリーファイルがstoriesディレクトリでまとめて置かれている構成ではなく、各コンポーネントディレクトリに置かれているとタイトル自動生成が冗長になる

例えば、components/SimpleForm/SimpleForm.stories.tsxにすると

SIMPLE FORM > Simple Form > Indexになってしまいます。
(ディレクトリ名とファイル名でSimpleFormが2回繰り返される)

冗長になってしまうのが嫌な方は今まで通りtitleを書いておくと良さそうです。

この問題についてもissueで指摘されているのでそのうち改善されると思います。

https://github.com/storybookjs/storybook/issues/15534#issuecomment-919868456

自分のリポジトリでtitleを省略しない対応してPRに上記のリンクを貼ったんですけど、そのことを忘れて上記のissue見返していたら自分のPRがそのissueに表示されてしまっていて(それはそう)とてもそわそわしました。油断してた、、、

インタラクティブストーリーの書き方

公式の記事
https://storybook.js.org/docs/react/essentials/interactions

CSF3ではplay関数が追加されました。これはストーリーがレンダリングされた後のアクションを記述できる関数です。例えば、SimpleFormのテキストフォームに名前を入力してSubmitボタンを押すという処理を実装する場合は以下のように書きます。

$ yarn add --dev @storybook/testing-library
compenents/SimpleForm.stories.tsx
import { ComponentMeta, ComponentStoryObj } from '@storybook/react'
import { within, userEvent } from '@storybook/testing-library'
import SimpleForm from './SimpleForm'

export default { component: SimpleForm } as ComponentMeta<typeof SimpleForm>

export const Index: ComponentStoryObj<typeof SimpleForm> = {
  args: {
    title: 'お名前フォーム'
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)

    await userEvent.type(canvas.getByRole('textbox'), 'yukishinonome', {
      delay: 100
    })
    userEvent.click(canvas.getByText('Submit'))
  }
}

以下のように、ストーリーがレンダリングされたタイミングでplay関数が実行されて、ユーザーのアクションに対する動作を再現することができます。

さらに、以下の設定を追加することで再生と巻き戻しを操作できるようになります。

$ yarn add --dev @storybook/addon-interactions
.storybook/main.js
...
  addons: [
    // 他のアドオン,
    '@storybook/addon-interactions'
  ],
  features: {
    interactionsDebugger: true
  },
...

ストーリーをユニットテストに応用する方法

公式の記事
https://storybook.js.org/docs/react/writing-tests/importing-stories-in-tests

CSF3ではJestと連携することが可能です。以下の2つの方法があります。

ストーリーファイルにテストを書く方法

$ yarn add --dev @storybook/jest
compenents/SimpleForm.stories.tsx
import { ComponentMeta, ComponentStoryObj } from '@storybook/react'
import { within, waitFor, userEvent } from '@storybook/testing-library'
import { expect } from '@storybook/jest'
import SimpleForm from './SimpleForm'

export default { component: SimpleForm } as ComponentMeta<typeof SimpleForm>

export const Index: ComponentStoryObj<typeof SimpleForm> = {
  args: {
    title: 'お名前フォーム'
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)

    await userEvent.type(canvas.getByRole('textbox'), 'yukishinonome', {
      delay: 100
    })
    userEvent.click(canvas.getByText('Submit'))

    await waitFor(() =>
      expect(canvas.getByTestId('print-name').textContent).toEqual(
        '入力した名前:yukishinonome'
      )
    )
  }
}

テストが失敗した場合

テストファイルにストーリーをimportしてテストを書く方法

$ yarn add --dev @storybook/testing-react
components/SimpleForm.test.tsx
import { render, screen } from '@testing-library/react'
import { composeStories } from '@storybook/testing-react'
import * as stories from './SimpleForm.stories'

const { Index } = composeStories(stories)

describe('SimpleForm', () => {
  it('入力したテキストが出力される', async () => {
    const { container } = render(<Index />)
    await Index.play({ canvasElement: container })

    expect(screen.getByTestId('print-name').textContent).toEqual(
      '入力した名前:yukishinonome'
    )
  })
})
テスト結果
$ yarn test components/SimpleForm.test.tsx
yarn run v1.22.17
$ jest components/SimpleForm.test.tsx
 PASS  components/SimpleForm.test.tsx
  SimpleForm
    ✓ 入力したテキストが出力される (1357 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.487 s, estimated 5 s
Ran all test suites matching /components\/SimpleForm.test.tsx/i.
✨  Done in 8.79s.

最後に

StorybookのCSF3は使い方次第で開発効率がとても向上しそうだなと思いました。特にStorybookとJestの連携はすごく便利なので、仕事でも活用できればいいなと考えています。

Discussion