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 にあったフォームを少しだけ調整したものを使ってみようと思います。
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 はこのような感じですね。

このコンポーネントに対する Story ファイルの基本構造は以下のようになります。
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 が表示されるようになりました 👏

実践例:フォームのテスト
バリデーションエラーのテスト
メールが空の状態で送信した場合のエラー表示をテストします。
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();
},
};
不正なメール形式の場合のテストも同様に書けます。
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();
},
};
チェックボックスの操作テスト
チェックボックスのオン/オフが正しく動作することをテストします。
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コールバックが正しい値で呼ばれることを検証します。
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,
});
},
};
ポイントは以下の通りで、普段のテストと同様です。
-
argsにfn()でモック関数を渡す -
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 を使っているなら、ぜひインタラクションテストを試してみてください 💪
Discussion