📕

Storybook CSF3.0で登録したStoryをJestで再利用する

2022/08/08に公開

はじめに

デザインシステムが盛り上がっている今日この頃、フロントエンド開発においてコンポーネントカタログとしてStorybookを導入している方は多いのではないでしょうか?

今回はStorybookをコンポーネントカタログとしてだけではなく、テストツールの一部としても使用する方法について書いていこうと思います。

前提

  • Storybookが導入されていること
  • Jest+Testing Libraryが導入されていること

テスト対象のコンポーネントの要件

今回はメールアドレスを入力するフォームコンポーネントをテスト対象とします。
要件は以下です。

  • inputのid属性とlabelのfor属性が同じであること
  • メールアドレスが未入力の場合は送信ボタンがdisabledであること

実際のコード

筆者がReactに慣れている都合上、Reactで実装した場合のコードになります。

ExampleForm.tsx
import React from "react"

export const ExampleForm = () => {
  const [email, setEmail] = React.useState("")

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        window.confirm(email)
      }}
      style={{
        display: "flex",
        flexDirection: "column",
      }}
    >
      <div>
        <label htmlFor="email">メールアドレス:</label>
        <input
          type="email"
          id="email"
          aria-label="メールアドレス"
          value={email}
          onChange={(e) => {
            setEmail(e.target.value)
          }}
          style={{ marginLeft: "8px" }}
        />
      </div>
      <input
        type="submit"
        aria-label="送信ボタン"
        value="送信"
        disabled={email === ""}
        style={{
          width: "50px",
          marginTop: "8px",
        }}
      />
    </form>
  )
}
ExampleForm.stories.tsx
import { ComponentMeta, ComponentStoryObj } from "@storybook/react"
import { ExampleForm } from "./ExampleForm"

export default {
  component: ExampleForm,
  parameters: {
    layout: "padded",
  },
} as ComponentMeta<typeof ExampleForm>

type Template = ComponentStoryObj<typeof ExampleForm>

export const Empty: Template = {}

StoryはCSF3.0の書き方を採用しています。
https://storybook.js.org/blog/component-story-format-3-0/

StoryをObjectとして定義できること、ComponentMetaComponentStoryObjのおかげでコンポーネントのpropsの型定義を別途importする必要がなくなったことで非常にシンプルに記述できるようになりました。

テストを書く

inputのid属性とlabelのfor属性が同じであること

まずはStorybookに登録したStoryをJestで再利用するためにStorybookから提供されている@storybook/testing-reactというライブラリをインストールします。

npm install --save-dev @storybook/testing-react
or
yarn add --dev @storybook/testing-react

@storybook/testing-reactcomposeStoriesという関数にstoriesを渡して返ってくるStoryをTesting Libraryのrenderでレンダーして使用します。
実際のテストコードは以下です。

ExampleForm.test.tsx
import { composeStories } from "@storybook/testing-react"
import { getByLabelText, getByText, render } from "@testing-library/react"
import * as stories from "./ExampleForm.stories"

describe("ExampleForm", () => {
  const { Empty } = composeStories(stories)

  test("inputのid属性とlabelのfor属性が同じであること", () => {
    const { container } = render(<Empty />)
    const input = getByLabelText(container, /メールアドレス/)
    const label = getByText(container, /メールアドレス:/) as HTMLLabelElement
    expect(input.id).toBe(label.htmlFor)
  })
})

これでStorybookに登録したStoryをJestで再利用することができました。
今回の例だとあまり旨味がないように見えますが、コンポーネントが引数を受け取ったり、モックデータを必要としたりする場合はStorybookにのみそれらを定義しておけばよいので、二重管理を防ぐことができて非常に便利です。

メールアドレスが未入力の場合は送信ボタンがdisabledであること

さて次は少し発展的なテストを書いてみましょう。

Storybook CSF3.0ではPlay Functionsという機能が追加されており、Story内で指定したインタラクションを実行することができます。
さらに上記のcomposeStoriesと組み合わせることで、Jestのテストケース内でも同様のインタラクションを実行できます。

まずはStory内で指定したインタラクションをStorybookで閲覧できるようにするためにStorybookから提供されている@storybook/addon-interactions@storybook/testing-libraryというライブラリをインストールします。

npm install --save-dev @storybook/addon-interactions @storybook/testing-library
or
yarn add --dev @storybook/addon-interactions @storybook/testing-library

アドオンの設定をします。

.storybook/main.js
module.exports = {
  addons: ['@storybook/addon-interactions'],
  features: {
    interactionsDebugger: true,
  },
}

それではStoryにインタラクションを記述していきましょう。
StoryのObjectのplayプロパティにインタラクションを記述します。
実際のコードは以下です。

ExampleForm.stories.tsx
import { ComponentMeta, ComponentStoryObj } from "@storybook/react"
import { userEvent, within } from "@storybook/testing-library"
import { ExampleForm } from "./ExampleForm"

export default {
  component: ExampleForm,
  parameters: {
    layout: "padded",
  },
} as ComponentMeta<typeof ExampleForm>

type Template = ComponentStoryObj<typeof ExampleForm>

export const Empty: Template = {}

export const Filled: Template = {
  ...Empty,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await userEvent.type(
      canvas.getByLabelText(/メールアドレス/),
      "hoge@example.com"
    )
  },
}

StorybookでExampleFormのFilledを開くと以下のようにメールアドレスが入力された状態で表示されます。
また、下部のInteractionsタブに実行されたインタラクションが表示され、適宜戻したり、進めたりすることができます。
ちなみにwithinuserEventはTesting Libraryでも同名の関数が存在しますが、@storybook/testing-libraryからimportしたものを使用しないと下部のInteractionsタブに実行されたインタラクションが表示されませんので注意してください。

Storybookの画面キャプチャ

さてこれでStoryにインタラクションを記述できたので、Jestのテストを書いていきましょう。
実際のテストコードは以下です。

ExampleForm.test.tsx
import { composeStories } from "@storybook/testing-react"
import "@testing-library/jest-dom"
import { getByLabelText, getByText, render } from "@testing-library/react"
import * as stories from "./ExampleForm.stories"

describe("ExampleForm", () => {
  const { Empty, Filled } = composeStories(stories)

  test("inputのid属性とlabelのfor属性が同じであること", () => {
    const { container } = render(<Empty />)
    const input = getByLabelText(container, /メールアドレス/)
    const label = getByText(container, /メールアドレス:/) as HTMLLabelElement
    expect(input.id).toBe(label.htmlFor)
  })

  test("メールアドレスが未入力の場合は送信ボタンがdisabledであること", async () => {
    const { container } = render(<Filled />)
    const button = getByLabelText(container, /送信ボタン/)
    expect(button).toBeDisabled()

    await Filled.play({ canvasElement: container })
    expect(button).not.toBeDisabled()
  })
})

Jestのテストケース内でも同様のインタラクションを実行するにはawait Filled.play({ canvasElement: container })のようにplay関数を実行するだけでOKです。

このようにすることでインタラクションのあるコンポーネントのテストもできます。

おわりに

今回はStorybookをコンポーネントカタログとしてだけではなく、テストツールの一部としても使用する方法について書いていきました。

今回はJest+Testing Libraryと連携したテストについて書いていきましたが、他にもStorybookをVRTとしても使用することでさらにStorybookに登録したStoryを再利用することができ、一石二鳥にも三鳥にもなると思います。

また、Hygenなどのコード生成ツールを使ってコンポーネントの雛形を作成するのと同時にStoryも作成することでStoryの登録が促進されるのでおすすめです。
https://zenn.dev/a_da_chi/articles/8eb5b2e06ed54b

Discussion