🐙

React Testing Library + MSW で SPA の統合テストを書いてみよう!

に公開

この記事で得られること

本記事では、React Testing LibraryMSW(Mock Service Worker) を使って、実際のユーザー操作や API 通信を含む統合テストをどのように書くかを、ステップごとに解説します。

「フロントエンドの統合テストを導入したいが、どこから始めればよいかわからない」という方の参考になれば幸いです。

直面した課題とその対策

私が扱っているプロダクトでは、フロントエンドテストは手動テストがメインとなっており、改修のたびに影響箇所を動作確認していました。これは、修正の頻度×影響範囲を鑑みてテスト工数を許容してきたという状況です。

しかし、言語やライブラリを定期更新しようとした時、修正の頻度×影響範囲が増大してそれに伴ってテスト工数は大きくなり、結果なかなか更新されないという状態に陥っていました。

これを回避するために自動テストの充実が必要で、Testing Trophy でいうところの「統合テスト」が無かったため、作成することにしました。

統合テストを書くにあたって、React Testing Library と MSW を組み合わせるとテストが書きやすかったので、本記事ではそのエッセンスを3つの例で紹介します。

環境構築

Vite + React + TypeScript の環境を使用します。

環境構築手順

node バージョン v22.14.0 をインストールした状態で次のコマンドを実行します。

 npm create vite@latest
    
 > npx
 > create-vite

 │
 ◇  Project name:
 │  react-testing-library
 │
 ◇  Select a framework:
 │  React
 │
 ◇  Select a variant:
 │  TypeScript
 │
 ◇  Use rolldown-vite (Experimental)?:
 │  No
 │
 ◇  Install with npm and start now?
 │  No
 │
 ◇  Scaffolding project in /user-directory/react-testing-library...
 │
 └  Done. Now run:

すると、以下のようなファイル群が作成されます。

├── eslint.config.js
├── index.html
├── package.json
├── public
│   └── vite.svg
├── README.md
├── src
│   ├── App.css
│   ├── App.tsx
│   ├── assets
│   │   └── react.svg
│   ├── index.css
│   └── main.tsx
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

package.json はこのようになっています。

package.json
{
  "name": "react-testing-library",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.1",
    "react-dom": "^19.1.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.36.0",
    "@types/react": "^19.1.13",
    "@types/react-dom": "^19.1.9",
    "@vitejs/plugin-react": "^5.0.3",
    "eslint": "^9.36.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
    "globals": "^16.4.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.44.0",
    "vite": "^7.1.7"
  }
}

次に、パッケージをインストールします。

npm install

最後に、npm run dev を実行して開発サーバを起動し、ブラウザアクセスできれば環境構築は完了です。

例1:ユーザー登録画面のテスト

ここでは、React Testing Library の基本的な使い方を紹介します。

題材として次のようなユーザー登録画面を例に挙げます。

姓・名・パスワードを入力し、登録ボタンを押すと「登録しました」と表示されるものです。
これに対して統合テストを書いていきます。

まずはアプリケーションの基本となるコードを作成します。

src/Form.tsx
import { useState } from 'react'

export const Form = () => {
    const [isSubmitted, setIsSubmitted] = useState(false)

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault()
        // ここでは擬似的な非同期処理として setTimeout を使っていますが、
        // 後ほど実際の API 通信に置き換えます。
        setTimeout(() => {
            setIsSubmitted(true)
        }, 3000)
    }

    return (
        <>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor="lastName"></label>
                    <input type="text" id="lastName" name="lastName" />
                </div>
                <div>
                    <label htmlFor="firstName"></label>
                    <input type="text" id="firstName" name="firstName" />
                </div>
                <div>
                    <label htmlFor="password">パスワード</label>
                    <input type="password" id="password" name="password" />
                </div>
                <button type="submit">登録</button>
            </form>
            {isSubmitted && <p>登録しました</p>}
        </>
    )
}
src/main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {Form} from "./Form.tsx";

createRoot(document.getElementById('root')!).render(
    <StrictMode>
        <Form/>
    </StrictMode>,
)

React Testing Library のセットアップ

まずは必要なライブラリをインストールします。

npm install --save-dev \
  @testing-library/dom \
  @testing-library/jest-dom \
  @testing-library/react \
  @testing-library/user-event \
  jsdom \
  vitest

次にテスト設定を追加します。
ポイントは environmentjsdom にすることです。

vite.config.ts
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
    plugins: [react()],
    // 追加
    test: {
        globals: true,
        watch: false,
        environment: 'jsdom',
    },
})

統合テストを作成

テストの流れは次のようになります。

  1. テスト対象のコンポーネントをレンダリングする(Arrange)
  2. ユーザー操作を行う(Act)
  3. 検証したい事象をアサートする(Assert)
src/Form.test.tsx
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/vitest'
import {Form} from './Form.tsx';

test('ユーザー登録操作のテスト', async () => {
    // Arrange
    // render を使ってテスト対象のコンポーネントをレンダリングする
    render(<Form/>)

    // Act
    // 各フィールドに値を入力する
    const user = userEvent.setup();
    const seiInput = screen.getByLabelText("姓");
    await user.type(seiInput, "sei");

    const meiInput = screen.getByLabelText("名");
    await user.type(meiInput, "mei");

    const passwordInput = screen.getByLabelText("パスワード");
    await user.type(passwordInput, "password");

    // 登録ボタンをクリックする
    await userEvent.click(screen.getByText('登録'))

    // Assert
    // 「登録しました」の文字列を検索し、存在することをアサートする
    // timeout を指定して最大5秒間待機します
    expect(await screen.findByText('登録しました', {}, {timeout: 5000})).toBeInTheDocument();
}, {timeout: 10000});

ポイントはシステムの内部詳細(ステート管理など)に触れず、モックすることもなく、UIの操作に専念している点です。これによりシステム内部の変更に影響を受けづらくなります。

React Testing Library の開発者である Kent C. Dodds 氏の言葉を借りると、
「テストがソフトウェアの使用方法に似ているほど、テストによって得られる信頼性が高まる」
と言う状態を作ることができます。

例2:API通信をモックする

ここでは、MSW を使って API 通信をモックし、バックエンドを用意せずに統合テストを書く方法を紹介します。

例1のユーザー登録画面で登録ボタンを押した時、API通信が行われるように実装を追加します。

src/Form.tsx
import { useState } from 'react'

export const Form = () => {
    const [submitStatus, setSubmitStatus] = useState<null | 'success' | 'error'>(null)
    const [lastName, setLastName] = useState('')
    const [firstName, setFirstName] = useState('')
    const [password, setPassword] = useState('')

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault()
        try {
            const response = await fetch('/api/register', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    lastName,
                    firstName,
                    password,
                }),
            })

            if (response.ok) {
                setSubmitStatus('success')
            } else {
                setSubmitStatus('error')
            }
        } catch (error) {
            console.error('Registration failed:', error)
            setSubmitStatus('error')
        }
    }

    return (
        <>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor="lastName"></label>
                    <input
                        type="text"
                        id="lastName"
                        name="lastName"
                        value={lastName}
                        onChange={(e) => setLastName(e.target.value)}
                    />
                </div>
                <div>
                    <label htmlFor="firstName"></label>
                    <input
                        type="text"
                        id="firstName"
                        name="firstName"
                        value={firstName}
                        onChange={(e) => setFirstName(e.target.value)}
                    />
                </div>
                <div>
                    <label htmlFor="password">パスワード</label>
                    <input
                        type="password"
                        id="password"
                        name="password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                    />
                </div>
                <button type="submit">登録</button>
            </form>
            {submitStatus === 'success' && <p>登録しました</p>}
            {submitStatus === 'error' && <p>登録できませんでした</p>}
        </>
    )
}

実装追加に伴って、テストコードを以下のように追加します。

  • APIリクエストに失敗した時、「登録できませんでした」が表示されること
  • APIリクエストボディに入力した値が入っていること

MSW のセットアップ

テストコード内でAPIリクエストを扱うために MSW をセットアップします。
セットアップは Quick start を参考に行います。
まず、MSW をインストールします。

npm i msw --save-dev

次に、ハンドラを定義し setupServer() に渡します。
ハンドラはリクエストをインターセプトし、そのレスポンスを処理する関数です。

src/Form.test.tsx
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/vitest'
import {Form} from './Form.tsx';
import {setupServer} from "msw/node";
import {http, HttpResponse} from "msw";

// リクエストボディをキャプチャするための変数。後でアサートするために使用します。
let requestBody: any = null;

// ハンドラ定義
const handlers = [
    // 指定のHTTPメソッド・URLパスをインターセプトし、指定の値を返す
    http.post('/api/register', async ({request}) => {
        // リクエストをキャプチャする
        requestBody = await request.json();
        return HttpResponse.json({}, {status: 200});
    })
]

const server = setupServer(...handlers);

Setup/Teardown 関数を使って前処理と後処理を行います。

src/Form.test.tsx

beforeAll(() => server.listen());
afterEach(() => {
    server.resetHandlers();
    // キャプチャしたリクエストボディは、テスト毎にリセットする
    requestBody = null;
});
afterAll(() => server.close());
  • server.listen
    • リクエストのインターセプトを有効化します。
  • server.resetHandlers
    • server.use を使ってテストケース内でハンドラを変更した場合も、本メソッドを呼ぶことでハンドラを初期状態にリセットします。
  • server.close
    • リクエストのインターセプトを停止します。

テストコードの追加

セットアップした MSW を使ってテストコードを追加します。
まずはAPIリクエストボディのテストです。

src/Form.test.tsx
test('ユーザー登録操作のテスト', async () => {
    // Arrange
    render(<Form/>)

    // Act
    const user = userEvent.setup();
    const seiInput = screen.getByLabelText("姓");
    await user.type(seiInput, "sei");

    const meiInput = screen.getByLabelText("名");
    await user.type(meiInput, "mei");

    const passwordInput = screen.getByLabelText("パスワード");
    await user.type(passwordInput, "password");

    // 登録ボタンをクリックする
    await userEvent.click(screen.getByText('登録'))

    // Assert
    expect(await screen.findByText('登録しました', {}, {timeout: 5000})).toBeInTheDocument();

    // 追加したテストコード。リクエストボディを検証する。
    expect(requestBody).toEqual({
        lastName: 'sei',
        firstName: 'mei',
        password: 'password'
    });
});

requestBody には、MSW のハンドラでインターセプトしたリクエストボディが格納されているため、その値が入力した値と一致するかを検証しています。
このように、MSW を使うことで実際のバックエンドサーバーを用意することなく、API通信を含む統合テストを書くことができます。

次に、APIレスポンスがエラーを返した時のテストを追加します。

src/Form.test.tsx
test('APIエラーの時、エラーメッセージが表示されること', async () => {
    // Arrange
    // ハンドラが 500 レスポンスを返すように変更する
    server.use(
        http.post('/api/register', async () => {
            return HttpResponse.json({}, {status: 500});
        })
    )
    render(<Form/>)

    // Act
    await userEvent.click(screen.getByText('登録'))

    // Assert
    // エラーメッセージの検証
    expect(await screen.findByText('登録できませんでした', {}, {timeout: 5000})).toBeInTheDocument();
});

ハンドラ定義では /api/register が 200 を返すようにしていましたが、このテストケースでは server.use を使って 500 を返すように変更しています。

このように API のデフォルトの挙動を定義しつつテストケースに応じて柔軟に変更することができ、server.resetHandlers() を呼ぶとデフォルトの状態に戻すことができます。

例3:Mantine UI に対応する

ここでは、UI コンポーネントライブラリである Mantine UI を使う場合の注意点を見ていきます。

例1のユーザー登録画面を Mantine UI 化します。

src/Form.tsx
export const Form = () => {
    // useState と handleSubmit は例2と同じため省略

    // 今回の変更箇所:Mantine UI のコンポーネントを使用
    return (
        <Paper shadow="xs" p="md" withBorder w={300}>
            <Stack>
                <form onSubmit={handleSubmit}>
                    <Stack>
                        <TextInput
                            label=""
                            value={lastName}
                            onChange={(e) => setLastName(e.currentTarget.value)}
                        />
                        <TextInput
                            label=""
                            value={firstName}
                            onChange={(e) => setFirstName(e.currentTarget.value)}
                        />
                        <PasswordInput
                            label="パスワード"
                            value={password}
                            onChange={(e) => setPassword(e.currentTarget.value)}
                        />
                        <Button type="submit">
                            登録
                        </Button>
                    </Stack>
                </form>
                {submitStatus === 'success' && <p>登録しました</p>}
                {submitStatus === 'error' && <p>登録できませんでした</p>}
            </Stack>
        </Paper>
    )
}
main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import '@mantine/core/styles.css';
import {Form} from "./Form.tsx";
import {MantineProvider} from "@mantine/core";

createRoot(document.getElementById('root')!).render(
    <StrictMode>
        <MantineProvider>
            <Form/>
        </MantineProvider>
    </StrictMode>,
)

このように見た目が整いました。

テストコードの変更

Mantine UI を使っているプロジェクトのテストについては、公式ドキュメントに案内があります。
今回の例ではテストコードを変更すべき所は2箇所です。

1つ目は、これまで render(<Form/>) でテスト対象コンポーネントをレンダリングしていましたが、MantineProvider も含めた次の形に変更します。

src/Form.test.tsx
render(
    <MantineProvider>
        <Form/>
    </MantineProvider>
)

2つ目は jsdom 環境では利用できない API(window.matchMedia)を Mantine Component が内部的に使用しているため、それらをモックします。

src/Form.test.tsx
import { vi } from 'vitest';

Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: vi.fn().mockImplementation((query) => ({
        matches: false,
        media: query,
        onchange: null,
        addListener: vi.fn(),
        removeListener: vi.fn(),
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        dispatchEvent: vi.fn(),
    })),
});

これでテストが通るようになります!

まとめ

React Testing Library と MSW を組み合わせることで、システムの内部実装に依存せず、ユーザーの操作に近い形で統合テストを書くことができました。

これによって、手動テストの負担を減らし、定期的なライブラリ更新にも自信を持って取り組める環境づくりへと、一歩踏み出せると思います。

参考

https://testing-library.com/docs/react-testing-library/example-intro

Discussion