TL;DR
慣れない React の Unit Test を保守することになった。幸い Storybook を使っていたので、理解を深めるための勉強メモを作成。
Glossary
合わせて押さえておきたい用語のメモ。
用語 | 説明 | 公式URL |
---|---|---|
MSW(Mock Service Worker) | ブラウザ/テスト環境で実際のHTTP通信を横取りしてモックできるライブラリ。フロントエンドだけでバックエンドがあるかのように振る舞わせられる。 | https://mswjs.io/ |
CSF(Component Story Format) | Storybook の推奨ストーリーフォーマット。export default でメタ情報、export const で各バリエーション(ストーリー)を定義する。CSF3 ではオブジェクト記法で簡潔に書ける。 |
https://storybook.js.org/docs/api/csf |
依存関係のセットアップ(React + TS 前提)
# 基本
npm i -D typescript vite @vitejs/plugin-react vitest jsdom
# テストまわり
npm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
# Storybook(Viteビルダー)
npx storybook@latest init --builder vite
# Storybook のテスト連携(play関数・composeStories 等)
npm i -D @storybook/test
# a11y チェック(任意)
npm i -D @storybook/addon-a11y
# モック共有(任意)
npm i -D msw @mswjs/interceptors
Vite / Vitest 設定
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setupTests.ts'],
css: true, // CSS import を許可
globals: true, // describe/it/expect をグローバルで使う
coverage: {
reporter: ['text', 'html'],
exclude: ['**/*.stories.*', '**/.storybook/**']
}
}
});
src/test/setupTests.ts
import '@testing-library/jest-dom';
Storybook 初期設定(Vite ビルダー)
基本設定:.storybook/main.ts
React + Vite 環境で Storybook を動かすための基本設定。
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials', // Controls / Docs / Actions / Viewport など
'@storybook/addon-interactions',
// 任意: '@storybook/addon-a11y'
],
framework: { name: '@storybook/react-vite', options: {} },
docs: { autodocs: 'tag' }
};
export default config;
役割 | 内容 |
---|---|
stories | どのストーリーファイルを読み込むか |
addons | 機能拡張(Docs, Controls, Interactions など) |
framework | React+Vite を使う指定 |
docs | 自動ドキュメント生成の方法 |
export default | Storybook に設定を渡す |
プレビュー設定:.storybook/preview.ts
Storybook 起動時に一度読み込まれる“プレビューの初期化スクリプト”。全ストーリー共通の文脈(Provider / テーマ / i18n / MSW など)や表示パラメータを定義。
import type { Preview } from '@storybook/react';
// グローバル decorator 例:Theme / i18n / Router / MSW などをここで包む
const preview: Preview = {
parameters: {
layout: 'centered',
controls: { expanded: true }
}
};
export default preview;
要素 | 役割 |
---|---|
layout: 'centered' |
コンポーネントを中央に配置 |
controls.expanded: true |
Controls パネルで Props を展開表示 |
decorators(コメント) | すべてのストーリーに共通する Provider やテーマ設定を入れる場所 |
Preview 型 |
設定の型安全性を確保 |
サンプル:コンポーネント/ストーリー/ユニットテスト
フォルダ構成(例)
src/components/Button/
Button.tsx
Button.stories.ts # 仕様サンプル&手動検証&自動テストの母体
Button.test.tsx # composeStories で論理検証
コンポーネント:src/components/Button/Button.tsx
import React from 'react';
type Props = {
label: string;
variant?: 'primary' | 'secondary';
disabled?: boolean;
onClick?: () => void;
};
export const Button: React.FC<Props> = ({ label, variant = 'primary', disabled, onClick }) => {
return (
<button
type="button"
disabled={disabled}
data-variant={variant}
onClick={onClick}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #ccc',
opacity: disabled ? 0.5 : 1
}}
>
{label}
</button>
);
};
ストーリー:src/components/Button/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { within, userEvent, expect } from '@storybook/test';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
args: { label: 'Save', variant: 'primary' },
parameters: { layout: 'centered' }
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {};
export const Disabled: Story = {
args: { disabled: true }
};
export const ClickToFire: Story = {
args: {
label: 'Fire',
onClick: () => {
const el = document.querySelector('#msg');
if (el) el.textContent = 'Fired!';
}
},
render: (args) => (
<>
<div id="msg" aria-live="polite" />
<Button {...args} />
</>
),
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(c.getByRole('button', { name: 'Fire' }));
await expect(c.getByText('Fired!')).toBeInTheDocument();
}
};
ユニットテスト:src/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react';
// Storybook 8 以降は '@storybook/react' または '@storybook/test' のどちらかに寄せる
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';
const { Primary, Disabled } = composeStories(stories);
describe('Button', () => {
it('Primary はラベルを表示', () => {
render(<Primary />);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('Disabled はクリック不可', () => {
render(<Disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
メモ:
getByRole('button', { name })
で名前も指定すると、複数ボタンがある場合でも堅牢。
MSW をストーリーとユニットで共用(任意だが強力)
src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => HttpResponse.json({ id: 1, name: 'Alice' }))
];
src/mocks/browser.ts
import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
.storybook/preview.ts(Storybook にだけ MSW を有効化する例)
import type { Preview } from '@storybook/react';
if (typeof window !== 'undefined') {
const { worker } = await import('../src/mocks/browser');
await worker.start();
}
const preview: Preview = {
parameters: { layout: 'centered' }
};
export default preview;
src/test/setupTests.ts(Vitest の Node 側)
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
これで Storybook と Vitest のモックが一致。差異起因のバグを減らせる。
Storybook Test Runner(play 関数をCIで実行)
storybook test
は Playwright ベースで各ストーリーの play
を実行。@storybook/addon-a11y
を入れていれば a11y 検査も同時に走る。
ローカル
npm run storybook # ターミナル1
npm run test:stories # ターミナル2
CI(GitHub Actions 例)
name: ui-checks
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build-storybook
- run: npx storybook test --ci
- run: npm run test -- --run
ビジュアルリグレッション(任意)
Chromatic を足すと、**「見た目が意図せず変わっていないか」**を PR で自動審査できる。
- run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}
運用のコツ(実務で効くところだけ)
-
ストーリーはUI状態のインデックス。バリエーションを中心に増やす。
-
相互作用は重要シナリオだけ
play
に、細かいロジックは Vitest に寄せる。-
play
は「最低限の重要シナリオ」に絞る。
-
-
getByRole
+ アクセシブルネームでセレクタを組み、堅牢性と可用性を両立。 -
スナップショット過多は避ける。振る舞いはアサーション、見た目は Chromatic に分担。
-
MSW + Decorators で API をストーリー単位にモック。Unit / Storybook / CI で同一モックにする。
このセットで「Storybook=UIの生きた仕様」「Vitest=ロジックと状態の検証」「storybook test=ブラウザ実行の相互作用チェック」を一本化できます。次はあなたの実プロジェクトの tsconfig
(@/
エイリアス)や Router / Theme の Provider を前提に、preview.tsx
のテンプレも仕上げられます。