0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 のテンプレも仕上げられます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?