ReactのコンポーネントをStorybookで一覧にして確かめる方法

​​React​Playwright,Storybook,TypeScript,Vite,Vitest

React のコンポーネントの「通常」「削除用」「押せない」といった状態を、アプリの画面に組み込まずに1つずつ確かめたいときは、Storybook に「ストーリー」として書いておきます。ストーリーは状態ごとにサイドバーへ並び、props をその場で切り替えたり、押したときの動きをテストとして実行したりできます。

筆者は検証環境で、Vite の React + TypeScript のプロジェクトに Storybook 10 を入れ、自作の Button のストーリーを書いて、画面の表示とテストの結果を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、npm 10.9.4、Chromium 141 です。使ったのは react / react-dom 19.3.1、storybook / @storybook/react-vite 10.6.3、vite 8.3.5、typescript 6.0.3、vitest 5.0.5、playwright 1.56.1 です。画面は、Storybook の開発サーバーを Chromium で開き、Playwright で操作して撮りました。

Vite の React プロジェクトに Storybook を入れる

まず npm create vite@latest app -- --template react-ts --no-interactive でプロジェクトを作り(--no-interactive は質問を省くために付けました)、その中で Storybook の初期設定のコマンドを実行しました。公式のインストールの手順では npm create storybook@latest を使います。筆者は、バージョンを 10.6.3 に固定し、CLI のオプションを付けて、次のように実行しました。

text
npx -y storybook@10.6.3 init --yes --no-dev --features docs --disable-telemetry --no-agent

付けたのは、質問に自動で答える --yes、入れる機能をドキュメントに絞る --features docs、終わったあとに開発サーバーを起動しない --no-dev、テレメトリーを送らない --disable-telemetry です。--no-agent も付けましたが、公式のオプションの一覧には載っておらず、役割は確かめられませんでした。

出力には、Storybook がプロジェクトを Vite と React の組み合わせ(react-vite)と判定したことと、追加したパッケージが表示されます(一部を抜き出しています)。

text
o  Framework detected: react-vite
|  Adding devDependencies:
|  - storybook@^10.6.3
|  - @storybook/react-vite@^10.6.3
|  - @storybook/addon-docs@^10.6.3
o  Storybook was successfully installed in your project!

途中で @storybook/addon-docs の設定について「Could not resolve the postinstall hook」という警告が出ましたが、すぐあとに「@storybook/addon-docs configured」と出て、このあとのドキュメントのページも表示できました。

コマンドが足したのは、設定のフォルダ .storybook(main.ts と preview.tsx)、package.json の2つのスクリプト(storybook と build-storybook)、見本のストーリーが入った src/stories です。筆者は見本を消し、.storybook/main.ts からは .mdx のファイルを探す行を消して、次の形にしました。

ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  "stories": [
    "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-docs"
  ],
  "framework": "@storybook/react-vite"
};
export default config;

stories に書いたパターンに合うファイル(ここでは src の下の *.stories.tsx など)が、ストーリーとして読み込まれます。開発サーバーは npm run storybook(中身は storybook dev -p 6006)で起動します。検証環境では、同じコマンドに --ci --no-open を付けて起動しました。起動のログに http://127.0.0.1:6006/ と出て、http://localhost:6006/ で開けました。

確かめたいコンポーネントとストーリーを書く

確かめる対象は、見た目の種類(variant)と大きさ(size)を選べる Button です。<button> と同じ props も受け取れるようにしています。

tsx
import type { ComponentProps } from 'react';
import './button.css';

type ButtonProps = ComponentProps<'button'> & {
  /** 見た目の種類 */
  variant?: 'primary' | 'secondary' | 'danger';
  /** 大きさ */
  size?: 'small' | 'medium';
};

export function Button({ variant = 'primary', size = 'medium', type = 'button', className, ...rest }: ButtonProps) {
  const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
  return <button type={type} className={classes} {...rest} />;
}

同じフォルダに、ストーリーのファイル Button.stories.tsx を置きます。次はその前半です。

tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn } from 'storybook/test';

import { Button } from './Button';

const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  args: {
    children: '保存する',
    onClick: fn(),
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {};

export const Secondary: Story = {
  args: { variant: 'secondary' },
};

export const Danger: Story = {
  args: { variant: 'danger', children: '削除する' },
};

export const Small: Story = {
  args: { size: 'small' },
};

export const Disabled: Story = {
  args: { disabled: true },
};

公式のストーリーの書き方にあるとおり、export default した meta がコンポーネント全体の情報で、名前を付けて export したものが1つずつのストーリーになります。title の / は、サイドバーでのグループの区切りです。

meta の args に書いた値は、すべてのストーリーに共通の propsとして渡され、ストーリーの args で上書きできます。上の例では、どのストーリーも「保存する」というラベルで描かれ、Danger だけが「削除する」になります。onClick に渡した fn() は、呼ばれた回数などをあとで調べられる関数で、後半のテストで使います。

satisfies Meta<typeof Button> と StoryObj<typeof meta> は、meta やストーリーの args に Button の props の型を付けるための書き方で、公式の TypeScript での書き方の説明にある形です。検証環境で npx tsc -p tsconfig.app.json --noEmit を実行したところ、型エラーは出ませんでした。

開発サーバーを開くと、サイドバーに Components › Button のグループができ、その下にストーリーが並びました。

Storybookの画面。左のサイドバーにButtonのDocs、Primary、Secondary、Danger、Small、Disabledなどが並び、右にPrimaryの「保存する」ボタンとControlsのパネルが表示されている
サイドバーに並んだ Button のストーリーと、選んだ Primary の表示

サイドバーの名前は、export した名前から作られます。ClickCallsOnClick は「Click Calls On Click」のように、単語の区切りに空白が入りました。

Controls で props を切り替えて見る

ストーリーを選ぶと、下の Controls のパネルに children、variant、size などの props が並びます。公式の Controls の説明によると、meta の component に渡したコンポーネントの型から、どの props にどんな入力欄を出すかを決めています。variant は、型に書いた3つの値がラジオボタンになりました。

検証環境で、Primary を開いたまま variant の「danger」を選んだところ、その場でボタンが削除用の色に変わりました。

Controlsのパネルでvariantのdangerを選んだ画面。上のボタンがピンク色に変わっている
Controls で variant を danger に切り替えたところ

全部の状態を1ページに並べる

meta に tags: ['autodocs'] を書くと、コンポーネントごとのドキュメントのページが作られ、サイドバーの「Docs」から開けます。このページの「Stories」の欄には、ストーリーが上から順に並びます。状態ごとの見た目を、1画面で見比べられます。

自動で作られたドキュメントのページ。Primary、Secondary、Danger、Small、Disabledのボタンが縦に並んでいる
ドキュメントのページに並んだ5つのストーリー

この画像には、後半で書くテスト用の2つのストーリーが入っていません。テスト用のストーリーには tags: ['!autodocs'] を付けて、ドキュメントのページから外しています。サイドバーには残ります。

play 関数で、押したときの動きを確かめる

ストーリーに play を書くと、ストーリーを描いたあとにその処理が動きます。公式のインタラクションテストの説明にあるとおり、canvas でストーリーの中の要素を探し、userEvent で操作し、expect で結果を確かめます。Button.stories.tsx の後半に、次の2つを足しました。

tsx
export const ClickCallsOnClick: Story = {
  tags: ['!autodocs'],
  play: async ({ args, canvas, userEvent }) => {
    await userEvent.click(canvas.getByRole('button', { name: '保存する' }));
    await expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};

export const DisabledDoesNotCallOnClick: Story = {
  tags: ['!autodocs'],
  args: { disabled: true },
  play: async ({ args, canvas, userEvent }) => {
    await userEvent.click(canvas.getByRole('button', { name: '保存する' }));
    await expect(args.onClick).not.toHaveBeenCalled();
  },
};

1つ目は、押すと onClick が1回呼ばれることを確かめます。2つ目は、disabled のときに押しても onClick が呼ばれないことを確かめます。

開発サーバーで ClickCallsOnClick を実行すると、Interactions のパネルに、クリックと expect の2つの手順が並び、「PASS」と出ました。

Interactionsのパネル。PASSの表示の下に、userEvent.clickとexpect(onClick).toHaveBeenCalledTimes(1)の2行にチェックが付いている
Interactions パネルに出た play 関数の結果

Vitest でストーリーをまとめてテストする

画面で1つずつ開かなくても、Vitest のアドオンを入れると、ストーリーをコマンドでまとめてテストできます。次のコマンドで入れました。

text
npx storybook add @storybook/addon-vitest --yes

このコマンドが、vitest や @vitest/browser-playwright などのパッケージを入れ、vite.config.ts にテストの設定を足しました。足された設定では、Playwright の Chromium でテストを動かします。

補足

公式の説明では、セットアップの途中で、必要なら Playwright のブラウザを入れるかどうかを聞かれます。検証環境では、すでに入っている Chromium 141 を使うため、それに合う playwright 1.56.1 を入れ直してから実行しました。なお、検証環境では環境変数 CI=1 を付けてアドオンを入れており、この質問に答える流れは試していません。

テストは npx vitest --project=storybook --run --reporter=verbose で実行しました。--reporter=verbose は、ストーリーごとの結果を出すために付けています(結果の部分を抜き出しています)。

text
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Primary 372ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Secondary 13ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Danger 9ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Small 11ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Disabled 7ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Click Calls On Click 23ms
 ✓ |storybook (chromium)| src/components/Button.stories.tsx > Disabled Does Not Call On Click 12ms
 Test Files  1 passed (1)
      Tests  7 passed (7)

play のない5つのストーリーも、テストとして数えられています。公式の説明によると、どのストーリーも、エラーなく描けるかを確かめる「スモークテスト」として動き、play があるものは、その中の expect も確かめます。

props の渡し忘れを、テストで見つける

テストが本当に間違いを見つけるかを確かめるため、Button をわざと壊しました。残りの props(...rest)を <button> に渡さず、children だけを渡す形です。元のコードとの違いは次のとおりです(壊した版に足した説明のコメント行は省いています)。

diff
-export function Button({ variant = 'primary', size = 'medium', type = 'button', className, ...rest }: ButtonProps) {
+export function Button({ variant = 'primary', size = 'medium', type = 'button', className, children }: ButtonProps) {
   const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
-  return <button type={type} className={classes} {...rest} />;
+  return <button type={type} className={classes}>{children}</button>;

もう一度テストを実行すると、押したときに onClick が呼ばれないため、ClickCallsOnClick が失敗しました(途中の行を省いています)。

text
 × |storybook (chromium)| src/components/Button.stories.tsx > Click Calls On Click 17ms
   → 
Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/components-button--click-calls-on-click&addonPanel=storybook/interactions/panel
expected "onClick" to be called 1 times, but got 0 times
 Test Files  1 failed (1)
      Tests  1 failed | 6 passed (7)

失敗したのは、この1つだけでした。DisabledDoesNotCallOnClick は成功のままです。壊した Button では onClick がそもそも渡らないので、押しても呼ばれないからです。disabled も渡らなくなっていますが、それを確かめるストーリーはありません。play 関数で確かめられるのは、expect に書いたことだけです。disabled が効かずにボタンが半透明にならない、といった見た目の違いは、ドキュメントのページなどで目で見て確かめる必要があります。

Button を元に戻すと、7つとも成功に戻りました。

静的なサイトとして書き出す

npm run build-storybook を実行すると、storybook-static フォルダに index.html などのファイルが書き出されました。公式の公開の手順によると、このフォルダは普通の Web サーバーに置いて公開できます。筆者は書き出しまでを試し、書き出したサイトを開くことと公開することは試していません。

ポイント

ストーリーは、状態ごとの props(args)を書いて名前を付けて export するだけで、サイドバーとドキュメントのページに並びます。押したときの動きは play 関数に書き、Vitest のアドオンを入れると、コマンドでまとめてテストできます。

​Posted by 86