Storybookハンズオン
こんにちは。6月で入社して1年になるtanesanです。
今回はStorybookの基本的な使い方をハンズオン形式でご紹介できればと思います。
Storybookとは
Storybookは、UIコンポーネントを独立して開発、テスト、ドキュメント化するためのツールです。Storybookを使用すると、コンポーネントを簡単に作成・プレビューし、テストすることができます。Storybookは、React、Vue等やその他のフレームワークで使用することができます。
Storybookは、コンポーネントをストーリーと呼ばれる独立したユースケースに分割します。ストーリーは、コンポーネントのプロパティや状態を変更することで、コンポーネントの様々な状態を視覚的に表現することができます。これによって、コンポーネントの機能や外観をテストしたり、コンポーネントのドキュメントを作成することができます。Storybookを使用することで、開発プロセスを迅速化し、チーム全体でコンポーネントの品質の向上が期待できます。
準備
今回はNext.jsプロジェクトを作成し、その上でコンポーネントとStoryを作成していくことにします。
Next.jsのインストール
下記コマンドでNext.jsをインストールします。実行後の設問はすべてデフォルトの選択肢を選択します。
npx create-next-app@latest // 執筆時のVer:13.4.4
Storybookのインストール
下記コマンドでStorybookをインストールします。実行後の設問はすべてデフォルトの選択肢を選択します。
npx storybook@latest init // 執筆時のVer:7.0.18
tailwindをStorybookで使用できるようにする
Next.jsのデフォルト設定でプロジェクトを作成した場合、tailwindを使うことができます。しかし、デフォルトではStorybook上でtailwindで設定したスタイルが反映されません。そこで、下記の通り変更します。
Storybookのプラグインの追加
yarn add -D @storybook/addon-styling postcss autoprefixer
preview.tsの変更
Storybook共通の設定は.storybook配下のpreview.tsで行っています。ここを下記の通り変更します。(※importの行)
import type { Preview } from '@storybook/react';
import '../app/globals.css'; // ←追加
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;
JestをStorybookで使用できるようにする
StoryのテストはStorybook上で行う方法と、テストファイルにStoryを取り込んで行う方法があります。今回は前者で行おうと思います。
JestをStorybookで実行するためのプラグインを追加する必要があります。
yarn add -D @storybook/jest
コンポーネントの作成
今回はButtonとそれを集約するButtonGroup、名前とメールアドレスの入力欄を持つFormという3つのコンポーネントについてStoryを作成していきたいと思います。
それぞれ下記のとおりです。
type ButtonProps = {
label?: string;
onClick?: () => void;
};
export const Button = ({ label = 'Button', ...props }: ButtonProps) => {
return (
<button className="bg-blue-500 rounded p-2 text-white w-fit" {...props}>
{label}
</button>
);
};
import { Button } from '../Button/Button';
type ButtonGroupProps = {
children:
| React.ReactElement<typeof Button>
| React.ReactElement<typeof Button>[];
direction?: 'row' | 'column';
};
export const ButtonGroup = ({ children, direction }: ButtonGroupProps) => {
if (direction === 'column')
return <div className="flex flex-col space-y-2">{children}</div>;
return <div className="flex flex-row space-x-2">{children}</div>;
};
import { InputHTMLAttributes } from 'react';
export const Form = () => {
return (
<form className="flex flex-col space-y-2">
<label htmlFor="name">Name</label>
<Input id="name" type="text" />
<label htmlFor="email">Email</label>
<Input id="email" type="email" />
</form>
);
};
const Input = (props: InputHTMLAttributes<HTMLInputElement>) => {
return (
<input
className="w-64 block w-full rounded-md border-0 p-1.5 text-gray-900 ring-2 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 "
{...props}
/>
);
};
以上で準備は終了です。
Storybookの基本的な使い方
まずはButtonを表示するだけのStoryを作ってみます。
import { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {};
Storybookを起動して確認してみます。
yarn storybook
下記の通り表示されていたらOKです。
Storyで使用するコンポーネントへのPropsの渡し方
Storyで使用するコンポーネントのプロップを設定したい場合、ざっくり3パターン方法があります。
- argsで渡す方法
- renderメソッドで渡す方法
- metaのargsで渡す方法
argsで渡す方法
StoryのargsオブジェクトにButtonのPropsを設定する方法です。
export const UseArgs: Story = {
args: {
label: 'Use args',
},
};
renderメソッドで渡す方法
Storyのrenderメソッドを使って直接Propsを指定したButtonを設定する方法です。
export const UseRenderMethod: Story = {
render: () => <Button label="Use render method" />,
};
また、render関数はargsを引数に取れるので、下記のように設定しても同じ出力を得ることができます。
export const UseRenderMethod: Story = {
render: (args) => <Button {...args} />,
args: {
label: 'Use render method',
},
};
metaのargsで渡す方法
metaにもargsオブジェクトを設定することができ、ここで設定した場合は、ファイル内すべてのStoryのargsに適用されます。※renderメソッドを使用している場合は反映されません
const meta: Meta<typeof Button> = {
component: Button,
args: {
label: 'Use meta args',
},
};
React Hooksを使用するStory
Story内でuseState等のReact Hooksを使いたい場合は、ファイル内で対象のコンポーネントをラップしたコンポーネントを作成してrender関数内で呼び出します。
const ButtonWithHooks = () => {
const [showText, setShowText] = useState(false);
return (
<>
<Button
label="Toggle Text"
onClick={() => setShowText((prev) => !prev)}
/>
{showText && <p>Hello</p>}
</>
);
};
export const UseHooks: Story = {
render: () => <ButtonWithHooks />,
};
Storyの再利用
Storyで設定したargsは再利用できます。下記はButtonGroupのStoryで使用するButtonに対してStoryを再利用する例です。
import { Meta, StoryObj } from '@storybook/react';
import { ButtonGroup } from './ButtonGroup';
import { Button } from '../Button/Button';
import { UseArgs, UseRenderMethod } from '../Button/Button.stories';
const meta: Meta<typeof ButtonGroup> = {
title: 'Component/ButtonGroup',
component: ButtonGroup,
};
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
export const ReuseButtonStory: Story = {
render: () => (
<ButtonGroup>
<Button {...UseArgs.args} />
{/* ↓argsのみの再利用なので、renderメソッドで渡したpropsは反映されない */}
<Button {...UseRenderMethod.args} />
</ButtonGroup>
),
};
Buttonに対して、UseArgsで設定したargsを展開しています。また、コメントでも書いた通りrenderメソッドで渡したpropsはargsには反映されないので、2個目のボタンのラベルには「Use render method」は表示されません。
play関数で振る舞いを検証
play関数を使えば、ユーザのアクションをStorybook内で再現することができます。下記は、Formコンポーネントで名前とメールアドレスの入力を再現するStoryを追加した例です。
import { Meta, StoryObj } from '@storybook/react';
import { Form } from './Form';
import { within, userEvent } from '@storybook/testing-library';
const meta: Meta<typeof Form> = {
title: 'Component/Form',
component: Form,
};
export default meta;
type Story = StoryObj<typeof Form>;
export const Default: Story = {};
export const InputName: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.getByRole('textbox', {
name: /Name/i,
});
await userEvent.type(input, 'Test');
},
};
export const InputEmail: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.getByRole('textbox', {
name: /Email/i,
});
await userEvent.type(input, 'sample@sample.net');
},
};
play関数の合成
play関数内で、他のStoryのplay関数を呼び出すことができます。※現状tsエラーが発生するようです。(issue)
export const InputNameAndEmail: Story = {
play: async ({ canvasElement }) => {
// tsエラーが発生するため、一旦ts-ignoreで回避
// issue https://github.com/storybookjs/storybook/issues/21573
//@ts-ignore
await InputName.play({ canvasElement });
//@ts-ignore
await InputEmail.play({ canvasElement });
},
};
Story内でテストの実行
play関数内で、jestの関数を呼び出すことができます。下記は、useStateを使ってButtonWithHooksコンポーネントを使って、クリック後にテキストが表示されているかを検証するStoryとなります。Interactionタブにテスト結果が表示されます。
export const ClickButton: Story = {
render: () => <ButtonWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(canvas.getByText('Hello')).toBeInTheDocument();
},
};
まとめ
以上、Storybookの基本的な使い方の紹介でした。今回紹介した機能以外にも、Storybookは豊富な機能を備えています。しっかりと整備することで、コンポーネントの開発やテストがよりスムーズに行えるようになると思います。私自身もまだまだ勉強中ですが、Storybookを使いこなし、高品質なアプリケーション開発に貢献できるように頑張りたいと思います。ぜひ皆さんも積極的にStorybookを活用してみてください!