🧪

Storybookインタラクションテスト入門 & Vitest Browser Modeとの比較

に公開

はじめに

こんにちは、ソニックムーブの原です 🐐

現在弊社では、品質向上を目的にテスト実装を強化しているところです。その中で、Storybook のインタラクションテストを試してみたので、学んだことをまとめたいと思います!

Storybook でインタラクションテストができるのをご存知でしょうか。play 関数を使うことで、UI の操作をそのままテストとして記述できます。

ここでは、Storybook のインタラクションテストの基本的な書き方を紹介します。また、普段 Vitest Browser Mode を使っている方向けに、両者の比較や使い分けについても触れていきます。

(私も普段は Vitest Browser Mode を使用してコンポーネントテストを実装しています 🎭)

3 行まとめ

  • play 関数で UI の操作とアサーションを記述できる
  • storybook/test からテストユーティリティをインポート
  • Vitest Browser Mode とは強みが異なるため、用途に応じて使い分け・併用が有効

Storybook インタラクションテストの概要

play 関数とは

各 Story に play 関数を定義することで、コンポーネントがレンダリングされた後に自動的に実行されるインタラクションを記述できます。

これにより以下のことが可能になります 🎉

  • ボタンのクリック、フォームへの入力などのユーザー操作のシミュレーション
  • 操作後の状態をアサーションでテスト
  • Storybook 上でテスト結果をビジュアルに確認

使用するライブラリ

storybook/testからテストユーティリティをインポートします。

import { expect, fn, userEvent, within } from "storybook/test";
  • within: Testing Library のクエリを提供
  • userEvent: ユーザー操作をシミュレート
  • expect: アサーション
  • fn: モック関数(Vitest のvi.fn()相当)

基本的な書き方

Story の構造

まず、テスト対象のコンポーネントを見てみましょう。

今回は Mantine の use-form にあったフォームを少しだけ調整したものを使ってみようと思います。

https://mantine.dev/form/use-form/

SignUpForm.tsx
import { Button, Checkbox, Group, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";

type FormValues = {
  email: string;
  termsOfService: boolean;
};

type Props = {
  onSubmit?: (values: FormValues) => void;
};

export function SignUpForm({ onSubmit = () => {} }: Props) {
  const form = useForm({
    mode: "uncontrolled",
    initialValues: {
      email: "",
      termsOfService: false,
    },

    validate: {
      email: (value) => {
        if (!value) return "Email is required";
        if (!/^\S+@\S+$/.test(value)) return "Invalid email";
        return null;
      },
    },
  });

  return (
    <form onSubmit={form.onSubmit((values) => onSubmit(values))}>
      <TextInput
        withAsterisk
        label="Email"
        placeholder="your@email.com"
        key={form.key("email")}
        {...form.getInputProps("email")}
      />

      <Checkbox
        mt="md"
        label="I agree to sell my privacy"
        key={form.key("termsOfService")}
        {...form.getInputProps("termsOfService", { type: "checkbox" })}
      />

      <Group justify="flex-end" mt="md">
        <Button type="submit">Submit</Button>
      </Group>
    </form>
  );
}

UI はこのような感じですね。

SignUpFormコンポーネントのUI

このコンポーネントに対する Story ファイルの基本構造は以下のようになります。

SignUpForm.stories.ts
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";

import { SignUpForm } from "./SignUpForm";

const meta = {
  title: "Components/SignUpForm",
  component: SignUpForm,
  tags: ["autodocs"],
} satisfies Meta<typeof SignUpForm>;

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

export const Default: Story = {};

これで Storybook に Story が表示されるようになりました 👏

Storybook

実践例:フォームのテスト

バリデーションエラーのテスト

メールが空の状態で送信した場合のエラー表示をテストします。

SignUpForm.stories.ts
export const EmptyEmailError: Story = {
  play: async ({ canvasElement }) => {
    // GIVEN
    const canvas = within(canvasElement);

    // WHEN
    await userEvent.click(canvas.getByRole("button", { name: "Submit" }));

    // THEN
    await expect(canvas.getByText("Email is required")).toBeInTheDocument();
  },
};

不正なメール形式の場合のテストも同様に書けます。

SignUpForm.stories.ts
export const InvalidEmailError: Story = {
  play: async ({ canvasElement }) => {
    // GIVEN
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByLabelText(/email/i), "invalid");

    // WHEN
    await userEvent.click(canvas.getByRole("button", { name: "Submit" }));

    // THEN
    await expect(canvas.getByText("Invalid email")).toBeInTheDocument();
  },
};

チェックボックスの操作テスト

チェックボックスのオン/オフが正しく動作することをテストします。

SignUpForm.stories.ts
export const ToggleCheckbox: Story = {
  play: async ({ canvasElement }) => {
    // GIVEN
    const canvas = within(canvasElement);
    const checkbox = canvas.getByRole("checkbox", {
      name: /I agree to sell my privacy/i,
    });
    await expect(checkbox).not.toBeChecked();

    // WHEN
    await userEvent.click(checkbox);

    // THEN
    await expect(checkbox).toBeChecked();

    // WHEN
    await userEvent.click(checkbox);

    // THEN
    await expect(checkbox).not.toBeChecked();
  },
};

送信成功のテスト(fn()によるモック)

正常に送信された場合、onSubmitコールバックが正しい値で呼ばれることを検証します。

SignUpForm.stories.ts
export const ValidEmailSubmit: Story = {
  args: {
    onSubmit: fn(),
  },
  play: async ({ canvasElement, args }) => {
    // GIVEN
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByLabelText(/email/i), "test@example.com");

    // WHEN
    await userEvent.click(canvas.getByRole("button", { name: "Submit" }));

    // THEN
    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      termsOfService: false,
    });
  },
};

ポイントは以下の通りで、普段のテストと同様です。

  • argsfn()でモック関数を渡す
  • play関数の引数からargsを受け取り、アサーションに使用

実際に Story を表示するとテストが実行され、ステップ実行で確認することもできます ✅

インタラクションテストの様子

Vitest Browser Mode との比較

共通点

両者には多くの共通点があります。

  • Testing Library ベースのクエリ(getByRole, getByTextなど)
  • 実際のブラウザ上でテストを実行
  • userEventによる操作シミュレーション

そのため、どちらかの書き方を覚えれば、もう一方にも応用できます。学習コストが抑えられるのは嬉しいですね 🥳

Storybook インタラクションテストの強み

強み 概要
ビジュアルデバッグ テスト失敗時に UI を見ながらステップ実行できる
ドキュメントとの一体化 Story がそのままコンポーネントの仕様書になる
非エンジニアとの共有 デザイナーや PM もブラウザで動作確認できる
即時フィードバック Storybook 上でリアルタイムにテスト結果を確認

Vitest Browser Mode の強み

強み 概要
高速な CI 実行 ヘッドレスブラウザで効率的に実行
既存テストとの統合 ユニットテスト・E2E と同じランナーで管理
柔軟なテスト構成 describe/it による細かいグルーピング
カバレッジ計測 Vitest のカバレッジ機能をそのまま活用

使い分けの指針

どちらを選ぶべきか

観点 Storybook Vitest Browser Mode
開発時のデバッグ
CI/CD
非エンジニア共有 ×
テスト実行速度
ドキュメント性
導入コスト

併用のアプローチ

両者は競合するものではなく、併用することで互いの強みを活かせます。

  • Storybook: コンポーネントカタログ + 主要シナリオのインタラクションテスト
  • Vitest Browser Mode: 網羅的なエッジケース + リグレッションテスト

まとめ

今回は Storybook のインタラクションテストについて紹介しました。

  • play 関数で UI の操作とアサーションを記述できる
  • storybook/test からテストユーティリティをインポート
  • Vitest Browser Mode とは強みが異なるため、用途に応じて使い分け・併用が有効

Storybook を使っているなら、ぜひインタラクションテストを試してみてください 💪

参考リンク

GitHubで編集を提案
株式会社ソニックムーブ

Discussion