Storybook CSF3.0で登録したStoryをJestで再利用する
はじめに
デザインシステムが盛り上がっている今日この頃、フロントエンド開発においてコンポーネントカタログとしてStorybookを導入している方は多いのではないでしょうか?
今回はStorybookをコンポーネントカタログとしてだけではなく、テストツールの一部としても使用する方法について書いていこうと思います。
前提
- Storybookが導入されていること
- Jest+Testing Libraryが導入されていること
テスト対象のコンポーネントの要件
今回はメールアドレスを入力するフォームコンポーネントをテスト対象とします。
要件は以下です。
- inputのid属性とlabelのfor属性が同じであること
- メールアドレスが未入力の場合は送信ボタンがdisabledであること
実際のコード
筆者がReactに慣れている都合上、Reactで実装した場合のコードになります。
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>
)
}
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の書き方を採用しています。
StoryをObjectとして定義できること、ComponentMeta
とComponentStoryObj
のおかげでコンポーネントの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-react
のcomposeStories
という関数にstoriesを渡して返ってくるStoryをTesting Libraryのrender
でレンダーして使用します。
実際のテストコードは以下です。
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
アドオンの設定をします。
module.exports = {
addons: ['@storybook/addon-interactions'],
features: {
interactionsDebugger: true,
},
}
それではStoryにインタラクションを記述していきましょう。
StoryのObjectのplay
プロパティにインタラクションを記述します。
実際のコードは以下です。
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タブに実行されたインタラクションが表示され、適宜戻したり、進めたりすることができます。
ちなみにwithin
とuserEvent
はTesting Libraryでも同名の関数が存在しますが、@storybook/testing-library
からimportしたものを使用しないと下部のInteractionsタブに実行されたインタラクションが表示されませんので注意してください。
さてこれでStoryにインタラクションを記述できたので、Jestのテストを書いていきましょう。
実際のテストコードは以下です。
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の登録が促進されるのでおすすめです。
Discussion