10日で覚えるJestDay 9: 実践プロジェクト
books.chapter 910日で覚えるJest

Day 9: 実践プロジェクト

今日学ぶこと

  • Todo Appを題材に、Day 1〜8の知識を総合的に活用する
  • ビジネスロジック層(TodoService)のユニットテスト
  • 外部API層(TodoAPI)のモックとインテグレーションテスト
  • Reactコンポーネント(TodoApp)のテスト
  • テストの整理・構造化のベストプラクティス
  • カバレッジレポートの確認

プロジェクト概要

今日は、シンプルなTodo Appを構築しながら、これまでに学んだテスト技法をすべて実践します。

flowchart TB
    subgraph UI["UIレイヤー"]
        COMP["TodoApp\nReactコンポーネント"]
    end
    subgraph BIZ["ビジネスロジック層"]
        SVC["TodoService\nCRUD操作・フィルタリング"]
    end
    subgraph DATA["データ層"]
        API["TodoAPI\n外部APIとの通信"]
    end
    COMP --> SVC
    SVC --> API
    style UI fill:#3b82f6,color:#fff
    style BIZ fill:#8b5cf6,color:#fff
    style DATA fill:#22c55e,color:#fff

機能一覧

機能 説明
追加 新しいTodoを作成する
切り替え Todoの完了/未完了を切り替える
削除 Todoを削除する
フィルタリング 全て / アクティブ / 完了済みで絞り込む

ディレクトリ構成

src/
├── todo/
│   ├── TodoAPI.js          # 外部API通信
│   ├── TodoService.js      # ビジネスロジック
│   ├── TodoApp.jsx         # Reactコンポーネント
│   └── __tests__/
│       ├── TodoAPI.test.js
│       ├── TodoService.test.js
│       ├── TodoApp.test.jsx
│       └── integration.test.js

Step 1: データ型の定義

まず、Todoのデータ構造を定義します。

// todo/types.js
/**
 * @typedef {Object} Todo
 * @property {string} id
 * @property {string} text
 * @property {boolean} completed
 * @property {string} createdAt
 */

TypeScript版:

// todo/types.ts
export interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: string;
}

export type FilterType = 'all' | 'active' | 'completed';

Step 2: TodoAPI — 外部API層

外部APIとの通信を担当するモジュールです。テストではこのモジュールをモックします。

// todo/TodoAPI.js
const API_BASE = 'https://api.example.com/todos';

const TodoAPI = {
  async fetchAll() {
    const res = await fetch(API_BASE);
    if (!res.ok) throw new Error('Failed to fetch todos');
    return res.json();
  },

  async create(text) {
    const res = await fetch(API_BASE, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, completed: false }),
    });
    if (!res.ok) throw new Error('Failed to create todo');
    return res.json();
  },

  async update(id, updates) {
    const res = await fetch(`${API_BASE}/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updates),
    });
    if (!res.ok) throw new Error('Failed to update todo');
    return res.json();
  },

  async remove(id) {
    const res = await fetch(`${API_BASE}/${id}`, {
      method: 'DELETE',
    });
    if (!res.ok) throw new Error('Failed to delete todo');
  },
};

module.exports = TodoAPI;

TodoAPIのユニットテスト

global.fetch をモックして、APIモジュールを単独でテストします。

// todo/__tests__/TodoAPI.test.js
const TodoAPI = require('../TodoAPI');

// global.fetch をモック
global.fetch = jest.fn();

describe('TodoAPI', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  describe('fetchAll', () => {
    test('returns todos on success', async () => {
      const mockTodos = [
        { id: '1', text: 'Learn Jest', completed: false },
      ];
      fetch.mockResolvedValue({
        ok: true,
        json: jest.fn().mockResolvedValue(mockTodos),
      });

      const result = await TodoAPI.fetchAll();

      expect(result).toEqual(mockTodos);
      expect(fetch).toHaveBeenCalledWith(
        'https://api.example.com/todos'
      );
    });

    test('throws error on failure', async () => {
      fetch.mockResolvedValue({ ok: false });

      await expect(TodoAPI.fetchAll()).rejects.toThrow(
        'Failed to fetch todos'
      );
    });
  });

  describe('create', () => {
    test('sends POST request and returns new todo', async () => {
      const newTodo = {
        id: '2',
        text: 'Write tests',
        completed: false,
      };
      fetch.mockResolvedValue({
        ok: true,
        json: jest.fn().mockResolvedValue(newTodo),
      });

      const result = await TodoAPI.create('Write tests');

      expect(fetch).toHaveBeenCalledWith(
        'https://api.example.com/todos',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({
            text: 'Write tests',
            completed: false,
          }),
        })
      );
      expect(result).toEqual(newTodo);
    });
  });

  describe('remove', () => {
    test('sends DELETE request', async () => {
      fetch.mockResolvedValue({ ok: true });

      await TodoAPI.remove('1');

      expect(fetch).toHaveBeenCalledWith(
        'https://api.example.com/todos/1',
        expect.objectContaining({ method: 'DELETE' })
      );
    });

    test('throws error on failure', async () => {
      fetch.mockResolvedValue({ ok: false });

      await expect(TodoAPI.remove('1')).rejects.toThrow(
        'Failed to delete todo'
      );
    });
  });
});
flowchart LR
    subgraph Test["テスト"]
        T["TodoAPI.test.js"]
    end
    subgraph Target["テスト対象"]
        A["TodoAPI.js"]
    end
    subgraph Mock["モック"]
        F["global.fetch\n(jest.fn())"]
    end
    T --> A --> F
    style Test fill:#3b82f6,color:#fff
    style Target fill:#8b5cf6,color:#fff
    style Mock fill:#f59e0b,color:#fff

Step 3: TodoService — ビジネスロジック層

TodoServiceはTodoAPIを利用してビジネスロジックを実装します。

// todo/TodoService.js
const TodoAPI = require('./TodoAPI');

class TodoService {
  constructor() {
    this.todos = [];
  }

  async loadTodos() {
    this.todos = await TodoAPI.fetchAll();
    return this.todos;
  }

  async addTodo(text) {
    if (!text || text.trim() === '') {
      throw new Error('Todo text cannot be empty');
    }
    const newTodo = await TodoAPI.create(text.trim());
    this.todos.push(newTodo);
    return newTodo;
  }

  async toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (!todo) {
      throw new Error(`Todo with id ${id} not found`);
    }
    const updated = await TodoAPI.update(id, {
      completed: !todo.completed,
    });
    todo.completed = updated.completed;
    return todo;
  }

  async deleteTodo(id) {
    await TodoAPI.remove(id);
    this.todos = this.todos.filter(t => t.id !== id);
  }

  getFilteredTodos(filter = 'all') {
    switch (filter) {
      case 'active':
        return this.todos.filter(t => !t.completed);
      case 'completed':
        return this.todos.filter(t => t.completed);
      default:
        return [...this.todos];
    }
  }

  getStats() {
    const total = this.todos.length;
    const completed = this.todos.filter(t => t.completed).length;
    const active = total - completed;
    return { total, completed, active };
  }
}

module.exports = TodoService;

TypeScript版:

// todo/TodoService.ts
import * as TodoAPI from './TodoAPI';
import { Todo, FilterType } from './types';

export class TodoService {
  private todos: Todo[] = [];

  async loadTodos(): Promise<Todo[]> {
    this.todos = await TodoAPI.fetchAll();
    return this.todos;
  }

  async addTodo(text: string): Promise<Todo> {
    if (!text || text.trim() === '') {
      throw new Error('Todo text cannot be empty');
    }
    const newTodo = await TodoAPI.create(text.trim());
    this.todos.push(newTodo);
    return newTodo;
  }

  async toggleTodo(id: string): Promise<Todo> {
    const todo = this.todos.find(t => t.id === id);
    if (!todo) {
      throw new Error(`Todo with id ${id} not found`);
    }
    const updated = await TodoAPI.update(id, {
      completed: !todo.completed,
    });
    todo.completed = updated.completed;
    return todo;
  }

  async deleteTodo(id: string): Promise<void> {
    await TodoAPI.remove(id);
    this.todos = this.todos.filter(t => t.id !== id);
  }

  getFilteredTodos(filter: FilterType = 'all'): Todo[] {
    switch (filter) {
      case 'active':
        return this.todos.filter(t => !t.completed);
      case 'completed':
        return this.todos.filter(t => t.completed);
      default:
        return [...this.todos];
    }
  }

  getStats(): { total: number; completed: number; active: number } {
    const total = this.todos.length;
    const completed = this.todos.filter(t => t.completed).length;
    const active = total - completed;
    return { total, completed, active };
  }
}

TodoServiceのユニットテスト

jest.mock() でTodoAPIモジュール全体をモックし、ビジネスロジックだけをテストします。

// todo/__tests__/TodoService.test.js
const TodoService = require('../TodoService');
const TodoAPI = require('../TodoAPI');

// TodoAPI モジュール全体をモック
jest.mock('../TodoAPI');

describe('TodoService', () => {
  let service;

  beforeEach(() => {
    service = new TodoService();
    jest.clearAllMocks();
  });

  // ----- loadTodos -----
  describe('loadTodos', () => {
    test('loads todos from API', async () => {
      const mockTodos = [
        { id: '1', text: 'Learn Jest', completed: false },
        { id: '2', text: 'Write tests', completed: true },
      ];
      TodoAPI.fetchAll.mockResolvedValue(mockTodos);

      const result = await service.loadTodos();

      expect(result).toEqual(mockTodos);
      expect(TodoAPI.fetchAll).toHaveBeenCalledTimes(1);
    });

    test('propagates API errors', async () => {
      TodoAPI.fetchAll.mockRejectedValue(
        new Error('Network error')
      );

      await expect(service.loadTodos()).rejects.toThrow(
        'Network error'
      );
    });
  });

  // ----- addTodo -----
  describe('addTodo', () => {
    test('adds a new todo', async () => {
      const newTodo = {
        id: '3',
        text: 'New task',
        completed: false,
        createdAt: '2025-01-01',
      };
      TodoAPI.create.mockResolvedValue(newTodo);

      const result = await service.addTodo('New task');

      expect(result).toEqual(newTodo);
      expect(TodoAPI.create).toHaveBeenCalledWith('New task');
      expect(service.getStats().total).toBe(1);
    });

    test('trims whitespace from text', async () => {
      TodoAPI.create.mockResolvedValue({
        id: '4',
        text: 'Trimmed',
        completed: false,
      });

      await service.addTodo('  Trimmed  ');

      expect(TodoAPI.create).toHaveBeenCalledWith('Trimmed');
    });

    test('throws error for empty text', async () => {
      await expect(service.addTodo('')).rejects.toThrow(
        'Todo text cannot be empty'
      );
      expect(TodoAPI.create).not.toHaveBeenCalled();
    });

    test('throws error for whitespace-only text', async () => {
      await expect(service.addTodo('   ')).rejects.toThrow(
        'Todo text cannot be empty'
      );
    });
  });

  // ----- toggleTodo -----
  describe('toggleTodo', () => {
    beforeEach(async () => {
      TodoAPI.fetchAll.mockResolvedValue([
        { id: '1', text: 'Task 1', completed: false },
        { id: '2', text: 'Task 2', completed: true },
      ]);
      await service.loadTodos();
    });

    test('toggles incomplete to complete', async () => {
      TodoAPI.update.mockResolvedValue({
        id: '1',
        completed: true,
      });

      const result = await service.toggleTodo('1');

      expect(result.completed).toBe(true);
      expect(TodoAPI.update).toHaveBeenCalledWith('1', {
        completed: true,
      });
    });

    test('toggles complete to incomplete', async () => {
      TodoAPI.update.mockResolvedValue({
        id: '2',
        completed: false,
      });

      const result = await service.toggleTodo('2');

      expect(result.completed).toBe(false);
    });

    test('throws error for non-existent id', async () => {
      await expect(service.toggleTodo('999')).rejects.toThrow(
        'Todo with id 999 not found'
      );
    });
  });

  // ----- deleteTodo -----
  describe('deleteTodo', () => {
    beforeEach(async () => {
      TodoAPI.fetchAll.mockResolvedValue([
        { id: '1', text: 'Task 1', completed: false },
        { id: '2', text: 'Task 2', completed: true },
      ]);
      await service.loadTodos();
    });

    test('removes todo from list', async () => {
      TodoAPI.remove.mockResolvedValue();

      await service.deleteTodo('1');

      expect(service.getStats().total).toBe(1);
      expect(TodoAPI.remove).toHaveBeenCalledWith('1');
    });
  });

  // ----- getFilteredTodos -----
  describe('getFilteredTodos', () => {
    beforeEach(async () => {
      TodoAPI.fetchAll.mockResolvedValue([
        { id: '1', text: 'Active 1', completed: false },
        { id: '2', text: 'Done 1', completed: true },
        { id: '3', text: 'Active 2', completed: false },
      ]);
      await service.loadTodos();
    });

    test('returns all todos with "all" filter', () => {
      const result = service.getFilteredTodos('all');
      expect(result).toHaveLength(3);
    });

    test('returns only active todos', () => {
      const result = service.getFilteredTodos('active');
      expect(result).toHaveLength(2);
      expect(result.every(t => !t.completed)).toBe(true);
    });

    test('returns only completed todos', () => {
      const result = service.getFilteredTodos('completed');
      expect(result).toHaveLength(1);
      expect(result[0].text).toBe('Done 1');
    });

    test('defaults to "all" filter', () => {
      const result = service.getFilteredTodos();
      expect(result).toHaveLength(3);
    });
  });

  // ----- getStats -----
  describe('getStats', () => {
    test('returns correct statistics', async () => {
      TodoAPI.fetchAll.mockResolvedValue([
        { id: '1', text: 'A', completed: false },
        { id: '2', text: 'B', completed: true },
        { id: '3', text: 'C', completed: false },
      ]);
      await service.loadTodos();

      expect(service.getStats()).toEqual({
        total: 3,
        completed: 1,
        active: 2,
      });
    });

    test('returns zeros for empty list', () => {
      expect(service.getStats()).toEqual({
        total: 0,
        completed: 0,
        active: 0,
      });
    });
  });
});
flowchart TB
    subgraph Tests["テストスイート構成"]
        L["loadTodos\n2テスト"]
        A["addTodo\n4テスト"]
        T["toggleTodo\n3テスト"]
        D["deleteTodo\n1テスト"]
        F["getFilteredTodos\n4テスト"]
        S["getStats\n2テスト"]
    end
    subgraph Mock["モック化された依存"]
        API["TodoAPI\n(jest.mock)"]
    end
    L --> API
    A --> API
    T --> API
    D --> API
    style Tests fill:#3b82f6,color:#fff
    style Mock fill:#f59e0b,color:#fff

Step 4: インテグレーションテスト

TodoServiceとTodoAPIを組み合わせたインテグレーションテストでは、global.fetch だけをモックし、モジュール間の連携を検証します。

// todo/__tests__/integration.test.js
const TodoService = require('../TodoService');

// jest.mock('../TodoAPI') は使わない!
// fetch だけをモックして、実際のモジュール連携をテスト
global.fetch = jest.fn();

describe('TodoService Integration', () => {
  let service;

  beforeEach(() => {
    service = new TodoService();
    jest.clearAllMocks();
  });

  test('full workflow: load, add, toggle, delete', async () => {
    // 1. Load initial todos
    fetch.mockResolvedValueOnce({
      ok: true,
      json: jest.fn().mockResolvedValue([
        { id: '1', text: 'Existing', completed: false },
      ]),
    });

    await service.loadTodos();
    expect(service.getStats().total).toBe(1);

    // 2. Add a new todo
    fetch.mockResolvedValueOnce({
      ok: true,
      json: jest.fn().mockResolvedValue({
        id: '2',
        text: 'New task',
        completed: false,
      }),
    });

    await service.addTodo('New task');
    expect(service.getStats().total).toBe(2);
    expect(service.getStats().active).toBe(2);

    // 3. Toggle the first todo
    fetch.mockResolvedValueOnce({
      ok: true,
      json: jest.fn().mockResolvedValue({
        id: '1',
        completed: true,
      }),
    });

    await service.toggleTodo('1');
    expect(service.getStats().completed).toBe(1);
    expect(service.getStats().active).toBe(1);

    // 4. Filter: only active
    const active = service.getFilteredTodos('active');
    expect(active).toHaveLength(1);
    expect(active[0].text).toBe('New task');

    // 5. Delete the completed todo
    fetch.mockResolvedValueOnce({ ok: true });

    await service.deleteTodo('1');
    expect(service.getStats().total).toBe(1);

    // Verify total fetch calls
    expect(fetch).toHaveBeenCalledTimes(4);
  });

  test('handles API failure during add gracefully', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: jest.fn().mockResolvedValue([]),
    });
    await service.loadTodos();

    fetch.mockResolvedValueOnce({ ok: false });

    await expect(service.addTodo('Will fail')).rejects.toThrow(
      'Failed to create todo'
    );

    // State should remain unchanged
    expect(service.getStats().total).toBe(0);
  });
});
flowchart LR
    subgraph Integration["インテグレーションテスト"]
        T["テスト"]
    end
    subgraph Real["実際のモジュール"]
        SVC["TodoService"]
        API["TodoAPI"]
    end
    subgraph Mock["モックのみ"]
        F["global.fetch"]
    end
    T --> SVC --> API --> F
    style Integration fill:#22c55e,color:#fff
    style Real fill:#8b5cf6,color:#fff
    style Mock fill:#f59e0b,color:#fff

ユニットテスト vs インテグレーションテスト: ユニットテストでは jest.mock('../TodoAPI') でAPI層を完全にモックし、ビジネスロジックだけを検証します。インテグレーションテストでは fetch のみをモックし、TodoService → TodoAPI の連携が正しく動作するかを確認します。


Step 5: Reactコンポーネント — TodoApp

// todo/TodoApp.jsx
import React, { useState, useEffect } from 'react';

export default function TodoApp({ todoService }) {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  const [inputText, setInputText] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    todoService
      .loadTodos()
      .then(loaded => {
        setTodos(loaded);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [todoService]);

  const handleAdd = async () => {
    try {
      setError('');
      const newTodo = await todoService.addTodo(inputText);
      setTodos(prev => [...prev, newTodo]);
      setInputText('');
    } catch (err) {
      setError(err.message);
    }
  };

  const handleToggle = async (id) => {
    const updated = await todoService.toggleTodo(id);
    setTodos(prev =>
      prev.map(t => (t.id === id ? { ...t, completed: updated.completed } : t))
    );
  };

  const handleDelete = async (id) => {
    await todoService.deleteTodo(id);
    setTodos(prev => prev.filter(t => t.id !== id));
  };

  const filtered = todos.filter(t => {
    if (filter === 'active') return !t.completed;
    if (filter === 'completed') return t.completed;
    return true;
  });

  if (loading) return <div role="status">Loading...</div>;

  return (
    <div>
      <h1>Todo App</h1>

      {error && <div role="alert">{error}</div>}

      <div>
        <input
          type="text"
          placeholder="What needs to be done?"
          value={inputText}
          onChange={e => setInputText(e.target.value)}
          aria-label="New todo"
        />
        <button onClick={handleAdd}>Add</button>
      </div>

      <nav aria-label="Filter">
        {['all', 'active', 'completed'].map(f => (
          <button
            key={f}
            onClick={() => setFilter(f)}
            aria-pressed={filter === f}
          >
            {f.charAt(0).toUpperCase() + f.slice(1)}
          </button>
        ))}
      </nav>

      <ul>
        {filtered.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
              aria-label={`Toggle ${todo.text}`}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
            }}>
              {todo.text}
            </span>
            <button
              onClick={() => handleDelete(todo.id)}
              aria-label={`Delete ${todo.text}`}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>

      <p>{todos.filter(t => !t.completed).length} items left</p>
    </div>
  );
}

Reactコンポーネントのテスト

Testing Libraryを使って、ユーザー操作の観点からテストします。

// todo/__tests__/TodoApp.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from '../TodoApp';

// TodoService のモック
function createMockService(initialTodos = []) {
  return {
    loadTodos: jest.fn().mockResolvedValue(initialTodos),
    addTodo: jest.fn(),
    toggleTodo: jest.fn(),
    deleteTodo: jest.fn(),
    getFilteredTodos: jest.fn(),
    getStats: jest.fn(),
  };
}

describe('TodoApp', () => {
  // ----- 初期表示 -----
  test('shows loading state then renders todos', async () => {
    const mockService = createMockService([
      { id: '1', text: 'Learn Jest', completed: false },
      { id: '2', text: 'Write tests', completed: true },
    ]);

    render(<TodoApp todoService={mockService} />);

    // Loading state
    expect(screen.getByRole('status')).toHaveTextContent(
      'Loading...'
    );

    // After loading
    await waitFor(() => {
      expect(screen.getByText('Learn Jest')).toBeInTheDocument();
    });
    expect(screen.getByText('Write tests')).toBeInTheDocument();
    expect(screen.getByText('1 items left')).toBeInTheDocument();
  });

  // ----- Todo追加 -----
  test('adds a new todo', async () => {
    const user = userEvent.setup();
    const mockService = createMockService([]);
    mockService.addTodo.mockResolvedValue({
      id: '1',
      text: 'New task',
      completed: false,
    });

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(
        screen.queryByRole('status')
      ).not.toBeInTheDocument();
    });

    const input = screen.getByLabelText('New todo');
    const addButton = screen.getByText('Add');

    await user.type(input, 'New task');
    await user.click(addButton);

    expect(mockService.addTodo).toHaveBeenCalledWith('New task');
    await waitFor(() => {
      expect(screen.getByText('New task')).toBeInTheDocument();
    });
    expect(input).toHaveValue('');
  });

  // ----- エラーハンドリング -----
  test('displays error when adding empty todo', async () => {
    const user = userEvent.setup();
    const mockService = createMockService([]);
    mockService.addTodo.mockRejectedValue(
      new Error('Todo text cannot be empty')
    );

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(
        screen.queryByRole('status')
      ).not.toBeInTheDocument();
    });

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

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(
        'Todo text cannot be empty'
      );
    });
  });

  // ----- Todo切り替え -----
  test('toggles a todo', async () => {
    const user = userEvent.setup();
    const mockService = createMockService([
      { id: '1', text: 'Task 1', completed: false },
    ]);
    mockService.toggleTodo.mockResolvedValue({
      id: '1',
      completed: true,
    });

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(screen.getByText('Task 1')).toBeInTheDocument();
    });

    const checkbox = screen.getByLabelText('Toggle Task 1');
    await user.click(checkbox);

    expect(mockService.toggleTodo).toHaveBeenCalledWith('1');
  });

  // ----- Todo削除 -----
  test('deletes a todo', async () => {
    const user = userEvent.setup();
    const mockService = createMockService([
      { id: '1', text: 'Task 1', completed: false },
    ]);
    mockService.deleteTodo.mockResolvedValue();

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(screen.getByText('Task 1')).toBeInTheDocument();
    });

    await user.click(screen.getByLabelText('Delete Task 1'));

    await waitFor(() => {
      expect(
        screen.queryByText('Task 1')
      ).not.toBeInTheDocument();
    });
  });

  // ----- フィルタリング -----
  test('filters todos by status', async () => {
    const user = userEvent.setup();
    const mockService = createMockService([
      { id: '1', text: 'Active task', completed: false },
      { id: '2', text: 'Done task', completed: true },
    ]);

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(screen.getByText('Active task')).toBeInTheDocument();
    });

    // Show completed only
    await user.click(screen.getByText('Completed'));
    expect(
      screen.queryByText('Active task')
    ).not.toBeInTheDocument();
    expect(screen.getByText('Done task')).toBeInTheDocument();

    // Show active only
    await user.click(screen.getByText('Active'));
    expect(screen.getByText('Active task')).toBeInTheDocument();
    expect(
      screen.queryByText('Done task')
    ).not.toBeInTheDocument();

    // Show all
    await user.click(screen.getByText('All'));
    expect(screen.getByText('Active task')).toBeInTheDocument();
    expect(screen.getByText('Done task')).toBeInTheDocument();
  });

  // ----- スナップショット -----
  test('matches snapshot', async () => {
    const mockService = createMockService([
      { id: '1', text: 'Snapshot test', completed: false },
    ]);

    const { container } = render(
      <TodoApp todoService={mockService} />
    );

    await waitFor(() => {
      expect(
        screen.getByText('Snapshot test')
      ).toBeInTheDocument();
    });

    expect(container).toMatchSnapshot();
  });

  // ----- ロードエラー -----
  test('shows error when loading fails', async () => {
    const mockService = createMockService();
    mockService.loadTodos.mockRejectedValue(
      new Error('Server unavailable')
    );

    render(<TodoApp todoService={mockService} />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(
        'Server unavailable'
      );
    });
  });
});
flowchart TB
    subgraph ComponentTests["コンポーネントテスト"]
        INIT["初期表示\nローディング → 描画"]
        ADD["Todo追加\n入力 → ボタン → 更新"]
        TOG["切り替え\nチェックボックス"]
        DEL["削除\nDeleteボタン"]
        FIL["フィルタリング\nAll/Active/Completed"]
        SNAP["スナップショット"]
        ERR["エラー表示"]
    end
    subgraph Approach["テストアプローチ"]
        UTL["Testing Library\nユーザー視点"]
        UE["userEvent\n実際の操作"]
    end
    INIT --> UTL
    ADD --> UE
    TOG --> UE
    DEL --> UE
    FIL --> UE
    style ComponentTests fill:#3b82f6,color:#fff
    style Approach fill:#22c55e,color:#fff

Step 6: テスト構造のベストプラクティス

テストファイルの命名規則

パターン 用途
*.test.js TodoService.test.js ユニットテスト
*.test.jsx TodoApp.test.jsx コンポーネントテスト
integration.test.js integration.test.js インテグレーションテスト

describe/test のネスト構造

describe('TodoService', () => {
  // Feature group
  describe('addTodo', () => {
    // Happy path
    test('adds a new todo with valid text', async () => {});

    // Edge cases
    test('trims whitespace from text', async () => {});

    // Error cases
    test('throws error for empty text', async () => {});
    test('throws error for whitespace-only text', async () => {});
  });
});

テストヘルパーの活用

テスト全体で共通するセットアップはヘルパー関数にまとめます。

// todo/__tests__/helpers.js
function createMockTodos(count = 3) {
  return Array.from({ length: count }, (_, i) => ({
    id: String(i + 1),
    text: `Todo ${i + 1}`,
    completed: i % 2 === 0,
    createdAt: new Date().toISOString(),
  }));
}

function createMockService(initialTodos = []) {
  return {
    loadTodos: jest.fn().mockResolvedValue(initialTodos),
    addTodo: jest.fn(),
    toggleTodo: jest.fn(),
    deleteTodo: jest.fn(),
    getFilteredTodos: jest.fn(),
    getStats: jest.fn(),
  };
}

module.exports = { createMockTodos, createMockService };

Step 7: カバレッジ付きでテスト実行

すべてのテストをカバレッジ付きで実行してみましょう。

npx jest --coverage --verbose todo/

出力例:

 PASS  todo/__tests__/TodoAPI.test.js
 PASS  todo/__tests__/TodoService.test.js
 PASS  todo/__tests__/integration.test.js
 PASS  todo/__tests__/TodoApp.test.jsx

Test Suites: 4 passed, 4 total
Tests:       22 passed, 22 total

-----------------------|---------|----------|---------|---------|
File                   | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files              |     100 |      100 |     100 |     100 |
 TodoAPI.js            |     100 |      100 |     100 |     100 |
 TodoService.js        |     100 |      100 |     100 |     100 |
 TodoApp.jsx           |     100 |      100 |     100 |     100 |
-----------------------|---------|----------|---------|---------|
flowchart TB
    subgraph Coverage["カバレッジ100%の達成"]
        U["ユニットテスト\nTodoAPI + TodoService"]
        I["インテグレーションテスト\nService + API連携"]
        C["コンポーネントテスト\nTodoApp"]
    end
    subgraph Result["結果"]
        R["全22テスト合格\nカバレッジ100%"]
    end
    U --> R
    I --> R
    C --> R
    style Coverage fill:#8b5cf6,color:#fff
    style Result fill:#22c55e,color:#fff

テスト戦略まとめ

flowchart TB
    subgraph Pyramid["テストピラミッド"]
        E2E["E2Eテスト\n少数・高コスト"]
        INT["インテグレーションテスト\n中程度"]
        UNIT["ユニットテスト\n多数・低コスト"]
    end
    style E2E fill:#ef4444,color:#fff
    style INT fill:#f59e0b,color:#fff
    style UNIT fill:#22c55e,color:#fff
テストレベル モック対象 検証内容 テスト数
ユニット 直接の依存(TodoAPI) ビジネスロジック単体 多い
インテグレーション 外部境界(fetch) モジュール間連携 中程度
コンポーネント Service層 UI操作とレンダリング 中程度

まとめ

概念 説明
レイヤー分離 API層・ビジネスロジック層・UI層を分離してテスト容易性を確保
jest.mock() モジュール全体をモックしてユニットテストを独立させる
jest.fn() モック関数でコールバックや依存を制御
インテグレーションテスト 最小限のモック(fetch)でモジュール連携を検証
Testing Library ユーザー視点でコンポーネントをテスト
カバレッジ --coverage フラグで未テスト箇所を特定
テスト構造 describe/testのネスト、ヘルパー関数で整理

重要ポイント

  1. ビジネスロジックとAPI通信を分離すると、テストが書きやすくなる
  2. ユニットテストでは jest.mock() で依存を置換し、テスト対象だけを検証する
  3. インテグレーションテストでは外部境界のみモックし、内部連携を確認する
  4. コンポーネントテストではユーザーの操作をシミュレートして動作を検証する
  5. カバレッジ100%を目指すが、意味のあるテストを優先する

練習問題

問題1: 基本

TodoServiceに updateText(id, newText) メソッドを追加し、そのユニットテストを書いてください。空文字の場合はエラーをスローするようにしましょう。

問題2: 応用

TodoAppコンポーネントに「完了済みを一括削除」ボタンを追加し、Testing Libraryでテストを書いてください。

チャレンジ問題

TodoServiceに以下の機能を追加し、ユニットテストとインテグレーションテストの両方を書いてください。

  • searchTodos(query): テキストに query を含むTodoを返す
  • sortTodos(field, order): 指定フィールドで並び替え('text' | 'createdAt'、'asc' | 'desc')

参考リンク


次回予告: Day 10では「CI/CDとベストプラクティス」について学びます。GitHub Actionsでの自動テスト、テスト戦略の設計、パフォーマンス最適化など、実務で役立つ知識を総まとめします!