フロントエンド

Storybookハンズオン

storybookハンズオン
tanesan

こんにちは。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です。

スクリーンショット 2023-05-29 14.47.19.png (46.8 kB)

Storyで使用するコンポーネントへのPropsの渡し方

Storyで使用するコンポーネントのプロップを設定したい場合、ざっくり3パターン方法があります。

  • argsで渡す方法
  • renderメソッドで渡す方法
  • metaのargsで渡す方法

argsで渡す方法

StoryのargsオブジェクトにButtonのPropsを設定する方法です。

export const UseArgs: Story = {
  args: {
    label: 'Use args',
  },
};
スクリーンショット 2023-05-29 14.57.22.png (8.8 kB)

renderメソッドで渡す方法

Storyのrenderメソッドを使って直接Propsを指定したButtonを設定する方法です。

export const UseRenderMethod: Story = {
  render: () => <Button label="Use render method" />,
};
スクリーンショット 2023-05-29 14.59.48.png (11.6 kB)

また、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',
  },
};
スクリーンショット 2023-05-29 15.37.48.png (10.6 kB)

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」は表示されません。

スクリーンショット 2023-05-29 15.48.44.png (11.4 kB)

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');
  },
};
スクリーンショット 2023-05-29 16.00.39.png (15.6 kB)
スクリーンショット 2023-05-29 16.01.04.png (19.6 kB)

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 });
  },
};
スクリーンショット 2023-05-29 16.19.45.png (20.9 kB)

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();
  },
};
スクリーンショット 2023-05-29 16.24.59.png (82.0 kB)

まとめ

以上、Storybookの基本的な使い方の紹介でした。今回紹介した機能以外にも、Storybookは豊富な機能を備えています。しっかりと整備することで、コンポーネントの開発やテストがよりスムーズに行えるようになると思います。私自身もまだまだ勉強中ですが、Storybookを使いこなし、高品質なアプリケーション開発に貢献できるように頑張りたいと思います。ぜひ皆さんも積極的にStorybookを活用してみてください!

AUTHOR
tanesan
tanesan
記事URLをコピーしました