10日で覚えるJestDay 6: Reactコンポーネントのテスト

Day 6: Reactコンポーネントのテスト

今日学ぶこと

  • React Testing Library とは何か、なぜ使うのか
  • renderscreenfireEvent の基本的な使い方
  • userEvent によるよりリアルな操作シミュレーション
  • getBy / queryBy / findBy クエリの使い分け
  • フォームのテスト方法
  • カスタムフックのテスト(renderHook
  • 実践例:Todoリストコンポーネントのテスト

React Testing Library とは

React Testing Library(RTL)は、Reactコンポーネントをユーザーの視点からテストするためのライブラリです。「10日で覚えるReact」で学んだコンポーネントの知識を活かして、それらが正しく動作するかを検証していきましょう。

従来のテスト手法では、コンポーネントの内部実装(state や props の値)を直接検証していました。RTL は「ユーザーがどう操作し、何が表示されるか」に焦点を当てます。

flowchart LR
    subgraph Old["従来のテスト手法"]
        IMPL["内部実装をテスト"]
        STATE["stateの値を検証"]
        PROPS["propsの受け渡しを検証"]
    end
    subgraph RTL["React Testing Library"]
        USER["ユーザー視点でテスト"]
        RENDER["画面に何が表示されるか"]
        INTERACT["操作したら何が起きるか"]
    end
    IMPL --> STATE
    IMPL --> PROPS
    USER --> RENDER
    USER --> INTERACT
    style Old fill:#ef4444,color:#fff
    style RTL fill:#22c55e,color:#fff

セットアップ

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

テストファイルの冒頭で @testing-library/jest-dom をインポートすると、DOM 専用のマッチャー(toBeInTheDocument() など)が使えるようになります。

import '@testing-library/jest-dom';

render, screen, fireEvent の基本

RTL の3つの基本 API を見ていきましょう。

API 役割
render() コンポーネントを仮想DOMにレンダリングする
screen レンダリングされたDOMを検索するユーティリティ
fireEvent DOM イベントを発火させる

はじめてのコンポーネントテスト

まずはシンプルなコンポーネントをテストしてみましょう。

// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Greeting from './Greeting';

test('renders greeting with the given name', () => {
  render(<Greeting name="Jest" />);

  // Find heading by its text content
  const heading = screen.getByText('Hello, Jest!');
  expect(heading).toBeInTheDocument();
});

TypeScript版:

// Greeting.tsx
type GreetingProps = {
  name: string;
};

function Greeting({ name }: GreetingProps) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;
// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Greeting from './Greeting';

test('renders greeting with the given name', () => {
  render(<Greeting name="Jest" />);

  const heading = screen.getByText('Hello, Jest!');
  expect(heading).toBeInTheDocument();
});

fireEvent でクリックをテスト

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default Counter;
// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Counter from './Counter';

test('increments count when button is clicked', () => {
  render(<Counter />);

  // Initial state
  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  // Click the button
  fireEvent.click(screen.getByText('Increment'));

  // After click
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

TypeScript版:

// Counter.tsx
import { useState } from 'react';

function Counter(): JSX.Element {
  const [count, setCount] = useState<number>(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default Counter;

userEvent による操作のシミュレーション

fireEvent は低レベルな DOM イベントを直接発火しますが、userEvent はユーザーの実際の操作をより忠実に再現します。

flowchart TB
    subgraph FE["fireEvent"]
        FE1["click → clickイベントのみ"]
        FE2["change → changeイベントのみ"]
    end
    subgraph UE["userEvent"]
        UE1["click → pointerdown → mousedown → focus → pointerup → mouseup → click"]
        UE2["type → focus → keydown → keypress → input → keyup(1文字ごと)"]
    end
    style FE fill:#f59e0b,color:#fff
    style UE fill:#22c55e,color:#fff
比較項目 fireEvent userEvent
イベントの再現度 単一イベントのみ 実際のブラウザ動作を再現
フォーカス管理 手動で管理 自動的に処理
テキスト入力 change で一括 1文字ずつ入力
推奨度 特殊なケースに 基本的にこちらを使う

userEvent の使い方

userEvent v14 以降は setup() を使ってインスタンスを作成します。

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments count with userEvent', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  // userEvent methods are async
  await user.click(screen.getByText('Increment'));

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

主な userEvent メソッド

メソッド 説明
user.click(element) クリック ボタン押下
user.dblClick(element) ダブルクリック 項目選択
user.type(element, text) テキスト入力 フォーム入力
user.clear(element) 入力クリア フォームリセット
user.selectOptions(element, values) セレクトボックス選択 ドロップダウン
user.tab() Tabキー フォーカス移動
user.keyboard(text) キーボード入力 ショートカットキー

getBy / queryBy / findBy の使い分け

RTL には3種類のクエリがあり、それぞれ挙動が異なります。これはテストを書く上で非常に重要な使い分けです。

flowchart TB
    Q["要素を検索したい"]
    Q --> EXISTS{"要素は存在する?"}
    EXISTS -->|"必ず存在する"| GETBY["getBy を使う"]
    EXISTS -->|"存在しないことを確認したい"| QUERYBY["queryBy を使う"]
    EXISTS -->|"非同期で表示される"| FINDBY["findBy を使う"]
    GETBY -->|"見つからない"| ERROR1["エラーをスロー"]
    QUERYBY -->|"見つからない"| NULL["null を返す"]
    FINDBY -->|"タイムアウト"| ERROR2["エラーをスロー"]
    style GETBY fill:#3b82f6,color:#fff
    style QUERYBY fill:#8b5cf6,color:#fff
    style FINDBY fill:#22c55e,color:#fff
クエリ 要素がない場合 非同期対応 主な用途
getBy* エラーをスロー 要素が存在することを前提としたテスト
queryBy* null を返す 要素が存在しないことを確認
findBy* エラーをスロー ✓ (Promise) 非同期で表示される要素を待つ

使い分けの実例

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

function Toggle() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <button onClick={() => setShow(!show)}>Toggle</button>
      {show && <p>Secret content</p>}
    </div>
  );
}

test('toggles content visibility', async () => {
  const user = userEvent.setup();
  render(<Toggle />);

  // queryBy: confirm element does NOT exist
  expect(screen.queryByText('Secret content')).not.toBeInTheDocument();

  await user.click(screen.getByText('Toggle'));

  // getBy: confirm element exists
  expect(screen.getByText('Secret content')).toBeInTheDocument();
});

findBy — 非同期表示の待機

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

test('displays user name after loading', async () => {
  // Mock fetch
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'Alice' }),
    })
  );

  render(<UserProfile userId={1} />);

  // Loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // findBy waits for the element to appear (default timeout: 1000ms)
  const userName = await screen.findByText('Alice');
  expect(userName).toBeInTheDocument();
});

クエリのサフィックス一覧

要素を検索するサフィックスも複数あります。アクセシビリティの観点から、上にあるものほど優先的に使いましょう。

サフィックス 説明 優先度
ByRole ARIAロールで検索 ★★★ 最優先
ByLabelText ラベルテキストで検索 ★★★
ByPlaceholderText プレースホルダーで検索 ★★☆
ByText テキストコンテンツで検索 ★★☆
ByDisplayValue 入力値で検索 ★★☆
ByAltText alt属性で検索 ★☆☆
ByTitle title属性で検索 ★☆☆
ByTestId data-testidで検索 ★☆☆ 最後の手段
// Prefer ByRole over ByText
screen.getByRole('button', { name: 'Submit' });  // Good
screen.getByText('Submit');                        // OK but less specific

// Use ByLabelText for form elements
screen.getByLabelText('Email');                    // Good
screen.getByPlaceholderText('Enter email');         // OK

// Use ByTestId only as a last resort
screen.getByTestId('custom-element');              // Last resort

フォームのテスト

フォームのテストは React コンポーネントテストの中でも特に重要です。「10日で覚えるReact」で学んだ制御コンポーネントの知識が役立ちます。

基本的なフォームテスト

// LoginForm.jsx
import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />

      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

test('submits form with email and password', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  // Fill in the form
  await user.type(screen.getByLabelText('Email'), 'test@example.com');
  await user.type(screen.getByLabelText('Password'), 'password123');

  // Submit the form
  await user.click(screen.getByRole('button', { name: 'Login' }));

  // Verify submission
  expect(handleSubmit).toHaveBeenCalledTimes(1);
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

test('does not submit when fields are empty', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  // Verify initial state
  expect(screen.getByLabelText('Email')).toHaveValue('');
  expect(screen.getByLabelText('Password')).toHaveValue('');
});

TypeScript版:

// LoginForm.tsx
import { useState, FormEvent } from 'react';

type LoginData = {
  email: string;
  password: string;
};

type LoginFormProps = {
  onSubmit: (data: LoginData) => void;
};

function LoginForm({ onSubmit }: LoginFormProps): JSX.Element {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />

      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;

バリデーションのテスト

// ValidationForm.jsx
import { useState } from 'react';

function ValidationForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!email.includes('@')) {
      setError('Please enter a valid email address');
      return;
    }
    setError('');
    onSubmit(email);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="text"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}
// ValidationForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ValidationForm from './ValidationForm';

test('shows error for invalid email', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<ValidationForm onSubmit={handleSubmit} />);

  // Enter invalid email
  await user.type(screen.getByLabelText('Email'), 'invalid-email');
  await user.click(screen.getByRole('button', { name: 'Submit' }));

  // Error message should appear
  expect(screen.getByRole('alert')).toHaveTextContent(
    'Please enter a valid email address'
  );

  // onSubmit should NOT be called
  expect(handleSubmit).not.toHaveBeenCalled();
});

test('submits valid email without error', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<ValidationForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'user@example.com');
  await user.click(screen.getByRole('button', { name: 'Submit' }));

  // No error should be displayed
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();

  // onSubmit should be called
  expect(handleSubmit).toHaveBeenCalledWith('user@example.com');
});

カスタムフックのテスト(renderHook)

「10日で覚えるReact」で学んだカスタムフックは、renderHook を使ってテストできます。コンポーネントを用意せずにフックの動作を直接テストできるため非常に便利です。

// useCounter.js
import { useState, useCallback } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('initializes with default value', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
});

test('initializes with custom value', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});

test('increments counter', () => {
  const { result } = renderHook(() => useCounter());

  // State updates must be wrapped in act()
  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('decrements counter', () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => {
    result.current.decrement();
  });

  expect(result.current.count).toBe(4);
});

test('resets counter to initial value', () => {
  const { result } = renderHook(() => useCounter(10));

  act(() => {
    result.current.increment();
    result.current.increment();
  });

  expect(result.current.count).toBe(12);

  act(() => {
    result.current.reset();
  });

  expect(result.current.count).toBe(10);
});

TypeScript版:

// useCounter.ts
import { useState, useCallback } from 'react';

type UseCounterReturn = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

function useCounter(initialValue: number = 0): UseCounterReturn {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

export default useCounter;

非同期カスタムフックのテスト

// useFetch.js
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetch(url)
      .then(res => res.json())
      .then(json => {
        if (!cancelled) {
          setData(json);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

export default useFetch;
// useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from './useFetch';

beforeEach(() => {
  global.fetch = jest.fn();
});

afterEach(() => {
  jest.restoreAllMocks();
});

test('fetches data successfully', async () => {
  const mockData = { id: 1, name: 'Alice' };
  global.fetch.mockResolvedValue({
    json: () => Promise.resolve(mockData),
  });

  const { result } = renderHook(() => useFetch('/api/users/1'));

  // Initially loading
  expect(result.current.loading).toBe(true);
  expect(result.current.data).toBeNull();

  // Wait for fetch to complete
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.data).toEqual(mockData);
  expect(result.current.error).toBeNull();
});

test('handles fetch error', async () => {
  global.fetch.mockRejectedValue(new Error('Network error'));

  const { result } = renderHook(() => useFetch('/api/users/1'));

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.error).toBe('Network error');
  expect(result.current.data).toBeNull();
});

実践例:Todoリストコンポーネントのテスト

ここまでの知識を統合して、実践的な Todo リストコンポーネントのテストを書いてみましょう。

コンポーネントの実装

// TodoList.jsx
import { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    if (input.trim() === '') return;
    setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
    setInput('');
  };

  const toggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <div>
        <label htmlFor="new-todo">New Todo</label>
        <input
          id="new-todo"
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Enter a new todo"
        />
        <button onClick={addTodo}>Add</button>
      </div>

      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <label>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleTodo(todo.id)}
                />
                <span
                  style={{
                    textDecoration: todo.completed ? 'line-through' : 'none',
                  }}
                >
                  {todo.text}
                </span>
              </label>
              <button onClick={() => deleteTodo(todo.id)}>Delete</button>
            </li>
          ))}
        </ul>
      )}

      {todos.length > 0 && (
        <p>
          {todos.filter(t => t.completed).length} / {todos.length} completed
        </p>
      )}
    </div>
  );
}

export default TodoList;

テストの実装

// TodoList.test.jsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import TodoList from './TodoList';

describe('TodoList', () => {
  let user;

  beforeEach(() => {
    user = userEvent.setup();
  });

  // --- Rendering ---

  test('renders the title', () => {
    render(<TodoList />);
    expect(screen.getByText('Todo List')).toBeInTheDocument();
  });

  test('shows empty state message when no todos', () => {
    render(<TodoList />);
    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  // --- Adding Todos ---

  test('adds a new todo', async () => {
    render(<TodoList />);

    await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    // Empty state should disappear
    expect(
      screen.queryByText('No todos yet. Add one above!')
    ).not.toBeInTheDocument();
  });

  test('clears input after adding a todo', async () => {
    render(<TodoList />);

    const input = screen.getByLabelText('New Todo');
    await user.type(input, 'Buy milk');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(input).toHaveValue('');
  });

  test('does not add empty todo', async () => {
    render(<TodoList />);

    // Try to add without typing anything
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  test('does not add whitespace-only todo', async () => {
    render(<TodoList />);

    await user.type(screen.getByLabelText('New Todo'), '   ');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  // --- Toggling Todos ---

  test('toggles todo completion', async () => {
    render(<TodoList />);

    // Add a todo
    await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    // Toggle completion
    const checkbox = screen.getByRole('checkbox');
    expect(checkbox).not.toBeChecked();

    await user.click(checkbox);
    expect(checkbox).toBeChecked();

    // Toggle back
    await user.click(checkbox);
    expect(checkbox).not.toBeChecked();
  });

  // --- Deleting Todos ---

  test('deletes a todo', async () => {
    render(<TodoList />);

    // Add a todo
    await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('Buy milk')).toBeInTheDocument();

    // Delete it
    await user.click(screen.getByRole('button', { name: 'Delete' }));

    expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
    // Empty state should reappear
    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  // --- Completion Counter ---

  test('shows completion counter', async () => {
    render(<TodoList />);

    // Add two todos
    await user.type(screen.getByLabelText('New Todo'), 'Buy milk');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    await user.type(screen.getByLabelText('New Todo'), 'Walk the dog');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('0 / 2 completed')).toBeInTheDocument();

    // Complete one todo
    const checkboxes = screen.getAllByRole('checkbox');
    await user.click(checkboxes[0]);

    expect(screen.getByText('1 / 2 completed')).toBeInTheDocument();
  });

  // --- Multiple Todos ---

  test('manages multiple todos independently', async () => {
    render(<TodoList />);

    // Add three todos
    const todosToAdd = ['Buy milk', 'Walk the dog', 'Read a book'];
    for (const todo of todosToAdd) {
      await user.type(screen.getByLabelText('New Todo'), todo);
      await user.click(screen.getByRole('button', { name: 'Add' }));
    }

    // All three should be visible
    todosToAdd.forEach(todo => {
      expect(screen.getByText(todo)).toBeInTheDocument();
    });

    // Delete the middle one
    const listItems = screen.getAllByRole('listitem');
    const middleItem = listItems[1];
    await user.click(within(middleItem).getByRole('button', { name: 'Delete' }));

    // Only two should remain
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    expect(screen.queryByText('Walk the dog')).not.toBeInTheDocument();
    expect(screen.getByText('Read a book')).toBeInTheDocument();
  });
});

TypeScript版:

// TodoList.tsx
import { useState } from 'react';

type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

function TodoList(): JSX.Element {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState('');

  const addTodo = (): void => {
    if (input.trim() === '') return;
    setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
    setInput('');
  };

  const toggleTodo = (id: number): void => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id: number): void => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // ... JSX is the same as the JavaScript version
}

まとめ

今日はReact Testing Libraryを使ったコンポーネントテストの基本を学びました。

トピック ポイント
React Testing Library ユーザー視点でテストする。内部実装に依存しない
render / screen コンポーネントをレンダリングし、DOMを検索する
fireEvent vs userEvent userEvent の方がリアルな操作を再現できる
getBy / queryBy / findBy 要素の存在/不在/非同期表示に応じて使い分ける
クエリの優先順位 ByRole > ByLabelText > ByText > ByTestId
フォームテスト getByLabelText でフォーム要素を取得し、userEvent.type で入力
renderHook カスタムフックを単独でテストできる

重要な原則:

  1. ユーザーがどう操作するかを基準にテストを書く
  2. 内部の state や props を直接テストしない
  3. アクセシビリティに配慮したクエリ(ByRoleByLabelText)を優先する
  4. userEventfireEvent より優先する

練習問題

問題1: 基本

以下の Alert コンポーネントのテストを書いてください。表示/非表示の切り替えと、閉じるボタンの動作を検証しましょう。

function Alert({ message, onClose }) {
  return (
    <div role="alert">
      <p>{message}</p>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

問題2: 応用

以下の SearchBox コンポーネントのテストを書いてください。テキスト入力と検索結果のフィルタリングを検証しましょう。

function SearchBox({ items }) {
  const [query, setQuery] = useState('');

  const filtered = items.filter(item =>
    item.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <label htmlFor="search">Search</label>
      <input
        id="search"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ul>
        {filtered.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
      <p>{filtered.length} results found</p>
    </div>
  );
}

チャレンジ問題

以下の useLocalStorage カスタムフックのテストを renderHook を使って書いてください。初期値の取得、値の更新、localStorage との同期を検証しましょう。

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

参考リンク


次回予告: Day 7では「スナップショットテスト」について学びます。コンポーネントのUI変更を自動的に検出する仕組みと、toMatchSnapshot() / toMatchInlineSnapshot() の使い方を見ていきましょう!