TypeScript + Storybook CSF3.0の書き方とユニットテストへの応用
概要
Storybook6.4からデフォルトでCSF3の書き方が使えるようになったので、自分なりに調べたことをまとめてみました。TypeScript + Reactで説明します。
公式の記事はこちら
この記事の内容は以下のとおりです。
- CSF2からCSF3の変更点
- CSFの既知の問題と公式の対応状況
- インタラクティブストーリーの書き方
- CSF3で書いたストーリーをユニットテストに応用する方法
この記事の説明で使うコンポーネント
名前を入力してSubmitボタンを押すと、ボタンを押した時の入力テキストを下に表示するコンポーネントです。
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のストーリーファイル
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のストーリーファイル
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する必要をなくす目的があるらしいです。
ComponentStoryObj
こちらの型はCSF3のために追加された型です。ストーリーが関数からオブジェクトに変わったのでオブジェクト用の型です。
play関数が使えるようになった
これについては後述の「インタラクティブストーリーの書き方」のところで説明します。
CSFの既知の問題と公式の対応状況
Propsの型が全てオプショナルになってしまう
デフォルトで設定する値と各ストーリーで追加で渡す値、という2階層を実現するためにそのような仕様にしているらしいです。しかし、この仕様によって型チェックがゆるくなり、後からコンポーネントのPropsを変更してもストーリーファイルで型エラーが発生しないので、ストーリーが壊れていることに気づきにくくなります。
この問題は以下で議論され、解決策が提案されているので近いうちに改善されそうです。
ストーリーファイルがstoriesディレクトリでまとめて置かれている構成ではなく、各コンポーネントディレクトリに置かれているとタイトル自動生成が冗長になる
例えば、components/SimpleForm/SimpleForm.stories.tsx
にすると
SIMPLE FORM > Simple Form > Index
になってしまいます。
(ディレクトリ名とファイル名でSimpleFormが2回繰り返される)
冗長になってしまうのが嫌な方は今まで通りtitle
を書いておくと良さそうです。
この問題についてもissueで指摘されているのでそのうち改善されると思います。
自分のリポジトリでtitle
を省略しない対応してPRに上記のリンクを貼ったんですけど、そのことを忘れて上記のissue見返していたら自分のPRがそのissueに表示されてしまっていて(それはそう)とてもそわそわしました。油断してた、、、
インタラクティブストーリーの書き方
公式の記事
CSF3ではplay関数が追加されました。これはストーリーがレンダリングされた後のアクションを記述できる関数です。例えば、SimpleFormのテキストフォームに名前を入力してSubmitボタンを押すという処理を実装する場合は以下のように書きます。
$ yarn add --dev @storybook/testing-library
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
...
addons: [
// 他のアドオン,
'@storybook/addon-interactions'
],
features: {
interactionsDebugger: true
},
...
ストーリーをユニットテストに応用する方法
公式の記事
CSF3ではJestと連携することが可能です。以下の2つの方法があります。
ストーリーファイルにテストを書く方法
$ yarn add --dev @storybook/jest
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
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