← トップページに戻る

TDD実践ガイド

👥第16章 将来の読み手を考えたテスト(抽象化)

TODOアプリにフィルタリング機能を追加しながら、将来の開発者が理解しやすいテスト設計と抽象化の実践を学びます。 保守性を重視したコードの書き方を習得します。

テストを書くのは、目の前のプログラミングを楽しくやりがいのあるものにするためだけでなく、 いま考えていることを将来の仲間に伝えるロゼッタストーンの役割も担ってほしいからだ。 だから、テストを書くときには読者のことを考えるのが何よりも大切だ。
— Kent Beck『テスト駆動開発』第16章より

📋TODOリスト

  • 混合タイプの処理(削除機能)
  • フィルタリング機能
  • 抽象化の実践
  • 将来の保守性を考慮したテスト設計

👀将来の読み手を考慮したテスト設計

テストは以下の読み手を想定して書く必要があります:

  • 未来の自分: 数ヶ月後に機能を拡張する際の参考資料
  • チームメンバー: コードレビューや共同開発時の理解促進
  • 新しい開発者: プロジェクトに参加した際の学習資料
  • ドキュメント化: テストケースが仕様書の役割を果たす

🔴1. レッド:フィルタリング機能の仕様をテストで表現

まず、フィルタリング機能の仕様を読みやすいテストで表現します。

components/TodoFilter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoFilter, { FilterType } from './TodoFilter';

describe('TodoFilter コンポーネントのテスト', () => {
  const mockOnFilterChange = jest.fn();
  
  beforeEach(() => {
    mockOnFilterChange.mockClear();
  });

  describe('フィルターオプションの表示', () => {
    it('すべてのフィルターオプションを明確に表示すること', () => {
      render(<TodoFilter currentFilter="ALL" onFilterChange={mockOnFilterChange} />);
      
      // 読み手にとって分かりやすい期待値
      expect(screen.getByText('すべて')).toBeInTheDocument();
      expect(screen.getByText('未完了')).toBeInTheDocument();
      expect(screen.getByText('完了済み')).toBeInTheDocument();
    });

    it('現在選択されているフィルターをハイライト表示すること', () => {
      render(<TodoFilter currentFilter="ACTIVE" onFilterChange={mockOnFilterChange} />);
      
      const activeButton = screen.getByText('未完了');
      const allButton = screen.getByText('すべて');
      
      // アクティブなフィルターは視覚的に区別される
      expect(activeButton).toHaveClass('active');
      expect(allButton).not.toHaveClass('active');
    });
  });
});

🏗️2. 型定義による意図の明確化

将来の読み手が理解しやすいように、明確な型定義を作成します。

types/FilterTypes.ts
/**
 * TODOアイテムのフィルタリングタイプ
 * 
 * - ALL: すべてのTODOアイテムを表示
 * - ACTIVE: 未完了のTODOアイテムのみ表示
 * - COMPLETED: 完了済みのTODOアイテムのみ表示
 */
export type FilterType = 'ALL' | 'ACTIVE' | 'COMPLETED';

/**
 * フィルタリング条件の判定関数の型
 */
export type FilterPredicate<T> = (item: T) => boolean;

/**
 * フィルタリング設定
 */
export interface FilterConfig {
  type: FilterType;
  label: string;
  description: string;
}

/**
 * 利用可能なフィルター設定
 */
export const FILTER_CONFIGS: Record<FilterType, FilterConfig> = {
  ALL: {
    type: 'ALL',
    label: 'すべて',
    description: 'すべてのTODOを表示'
  },
  ACTIVE: {
    type: 'ACTIVE',
    label: '未完了',
    description: '未完了のTODOのみ表示'
  },
  COMPLETED: {
    type: 'COMPLETED',
    label: '完了済み',
    description: '完了済みのTODOのみ表示'
  }
};

🟢3. グリーン:読みやすい実装

明確で理解しやすいコンポーネントを実装します。

components/TodoFilter.tsx
"use client";

import React from 'react';
import { FilterType, FILTER_CONFIGS } from '../types/FilterTypes';

interface TodoFilterProps {
  currentFilter: FilterType;
  onFilterChange: (filter: FilterType) => void;
}

/**
 * TODOアイテムのフィルタリングコンポーネント
 * 
 * 将来の読み手への配慮:
 * - 明確な命名規則
 * - 型安全性の確保
 * - 設定の外部化
 */
export default function TodoFilter({ currentFilter, onFilterChange }: TodoFilterProps) {
  return (
    <div className="filter-container">
      <h3>フィルター</h3>
      <div className="filter-buttons">
        {Object.values(FILTER_CONFIGS).map((config) => (
          <button
            key={config.type}
            className={`filter-button ${currentFilter === config.type ? 'active' : ''}`}
            onClick={() => onFilterChange(config.type)}
            title={config.description}
          >
            {config.label}
          </button>
        ))}
      </div>
    </div>
  );
}

🎯4. 抽象化されたフィルタリングロジック

将来の拡張を考慮して、フィルタリングロジックを抽象化します。

utils/todoFilters.ts
import { Todo } from '../types/TodoActions';
import { FilterType, FilterPredicate } from '../types/FilterTypes';

/**
 * フィルタリング述語関数のマップ
 * 新しいフィルタータイプを追加する際は、ここに述語関数を追加する
 */
const FILTER_PREDICATES: Record<FilterType, FilterPredicate<Todo>> = {
  ALL: () => true,
  ACTIVE: (todo) => !todo.completed,
  COMPLETED: (todo) => todo.completed
};

/**
 * TODOリストを指定されたフィルタータイプでフィルタリングする
 * 
 * @param todos - フィルタリング対象のTODOリスト
 * @param filterType - フィルタリングタイプ
 * @returns フィルタリングされたTODOリスト
 * 
 * @example
 * ```typescript
 * const allTodos = [
 *   { id: 1, text: "Reactを学ぶ", completed: true },
 *   { id: 2, text: "TypeScriptを学ぶ", completed: false }
 * ];
 * 
 * const activeTodos = filterTodos(allTodos, 'ACTIVE');
 * // Result: [{ id: 2, text: "TypeScriptを学ぶ", completed: false }]
 * ```
 */
export function filterTodos(todos: Todo[], filterType: FilterType): Todo[] {
  const predicate = FILTER_PREDICATES[filterType];
  
  if (!predicate) {
    console.warn(`Unknown filter type: ${filterType}. Falling back to 'ALL'.`);
    return todos;
  }
  
  return todos.filter(predicate);
}

🧪5. フィルタリングロジックの単体テスト

将来の読み手が理解しやすい、実用的なテストケースを作成します。

utils/todoFilters.test.ts
import { filterTodos } from './todoFilters';
import { Todo } from '../types/TodoActions';

describe('todoFilters ユーティリティ関数のテスト', () => {
  // テストデータの準備(読み手にとって理解しやすいデータセット)
  const sampleTodos: Todo[] = [
    { id: 1, text: 'Reactの基礎を学ぶ', completed: true },
    { id: 2, text: 'TODOアプリを作る', completed: false },
    { id: 3, text: 'テストを書く', completed: true },
    { id: 4, text: '本番環境にデプロイ', completed: false },
  ];

  describe('filterTodos 関数のテスト', () => {
    it('フィルターがALLの場合、すべてのTODOを返すこと', () => {
      const result = filterTodos(sampleTodos, 'ALL');
      
      expect(result).toHaveLength(4);
      expect(result).toEqual(sampleTodos);
    });

    it('フィルターがACTIVEの場合、未完了のTODOのみ返すこと', () => {
      const result = filterTodos(sampleTodos, 'ACTIVE');
      
      expect(result).toHaveLength(2);
      expect(result.every(todo => !todo.completed)).toBe(true);
      expect(result.map(todo => todo.text)).toEqual([
        'TODOアプリを作る',
        '本番環境にデプロイ'
      ]);
    });

    it('フィルターがCOMPLETEDの場合、完了済みのTODOのみ返すこと', () => {
      const result = filterTodos(sampleTodos, 'COMPLETED');
      
      expect(result).toHaveLength(2);
      expect(result.every(todo => todo.completed)).toBe(true);
      expect(result.map(todo => todo.text)).toEqual([
        'Reactの基礎を学ぶ',
        'テストを書く'
      ]);
    });

    it('空のTODOリストを適切に処理すること', () => {
      const emptyList: Todo[] = [];
      
      expect(filterTodos(emptyList, 'ALL')).toEqual([]);
      expect(filterTodos(emptyList, 'ACTIVE')).toEqual([]);
      expect(filterTodos(emptyList, 'COMPLETED')).toEqual([]);
    });
  });
});

🔗6. 統合テスト:実際の使用シナリオ

実際の使用シナリオに基づいた統合テストを作成します。

components/TodoApp.test.tsx
describe('TodoApp 統合テスト - フィルタリング機能', () => {
  describe('実際のフィルタリングシナリオ', () => {
    it('フィルタリングを含む完全なワークフローを実証すること', () => {
      render(<TodoApp />);
      
      // ===== セットアップフェーズ =====
      const input = screen.getByRole('textbox');
      const submitButton = screen.getByRole('button', { name: /todo を追加/i });
      
      // 多様なTODOアイテムを追加
      const todosToAdd = [
        'React Testing Libraryを学ぶ',
        '包括的なテストを書く',
        'プルリクエストをレビュー',
        'ステージング環境にデプロイ'
      ];
      
      todosToAdd.forEach(todoText => {
        fireEvent.change(input, { target: { value: todoText } });
        fireEvent.click(submitButton);
      });
      
      // 一部のTODOを完了にする
      const checkboxes = screen.getAllByRole('checkbox');
      fireEvent.click(checkboxes[0]); // 'React Testing Libraryを学ぶ'を完了
      fireEvent.click(checkboxes[2]); // 'プルリクエストをレビュー'を完了
      
      // ===== フィルタリングテストフェーズ =====
      
      // 1. すべてのTODOが表示されることを確認
      expect(screen.getByText('React Testing Libraryを学ぶ')).toBeInTheDocument();
      expect(screen.getByText('包括的なテストを書く')).toBeInTheDocument();
      expect(screen.getByText('プルリクエストをレビュー')).toBeInTheDocument();
      expect(screen.getByText('ステージング環境にデプロイ')).toBeInTheDocument();
      
      // 2. 未完了フィルターをテスト
      const activeFilterButton = screen.getByText('未完了');
      fireEvent.click(activeFilterButton);
      
      expect(screen.queryByText('React Testing Libraryを学ぶ')).not.toBeInTheDocument();
      expect(screen.getByText('包括的なテストを書く')).toBeInTheDocument();
      expect(screen.queryByText('プルリクエストをレビュー')).not.toBeInTheDocument();
      expect(screen.getByText('ステージング環境にデプロイ')).toBeInTheDocument();
      
      // 3. 完了済みフィルターをテスト
      const completedFilterButton = screen.getByText('完了済み');
      fireEvent.click(completedFilterButton);
      
      expect(screen.getByText('React Testing Libraryを学ぶ')).toBeInTheDocument();
      expect(screen.queryByText('包括的なテストを書く')).not.toBeInTheDocument();
      expect(screen.getByText('プルリクエストをレビュー')).toBeInTheDocument();
      expect(screen.queryByText('ステージング環境にデプロイ')).not.toBeInTheDocument();
      
      // 4. すべてフィルターに戻す
      const allFilterButton = screen.getByText('すべて');
      fireEvent.click(allFilterButton);
      
      expect(screen.getByText('React Testing Libraryを学ぶ')).toBeInTheDocument();
      expect(screen.getByText('包括的なテストを書く')).toBeInTheDocument();
      expect(screen.getByText('プルリクエストをレビュー')).toBeInTheDocument();
      expect(screen.getByText('ステージング環境にデプロイ')).toBeInTheDocument();
    });
  });
});

🎛️7. 完全なTodoAppコンポーネント

フィルタリング機能を統合した完全なアプリケーションコンポーネントです。

components/TodoApp.tsx
"use client";

import React, { useState } from 'react';
import TodoForm from './TodoForm';
import TodoItem from './TodoItem';
import TodoFilter, { FilterType } from './TodoFilter';
import { useTodoReducer } from '../hooks/useTodoReducer';
import { filterTodos } from '../utils/todoFilters';

export default function TodoApp() {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodoReducer();
  const [currentFilter, setCurrentFilter] = useState<FilterType>('ALL');
  
  // フィルタリングされたTODOリストを取得
  const filteredTodos = filterTodos(todos, currentFilter);
  
  // 統計情報の計算
  const totalTodos = todos.length;
  const activeTodos = todos.filter(todo => !todo.completed).length;
  const completedTodos = todos.filter(todo => todo.completed).length;

  return (
    <div className="todo-app">
      <h1>フィルタリング機能付きTODOアプリ</h1>
      
      <TodoForm onSubmit={addTodo} />
      
      {totalTodos > 0 && (
        <>
          <div className="todo-stats">
            <span>合計: {totalTodos}</span>
            <span>未完了: {activeTodos}</span>
            <span>完了済み: {completedTodos}</span>
          </div>
          
          <TodoFilter 
            currentFilter={currentFilter} 
            onFilterChange={setCurrentFilter} 
          />
        </>
      )}
      
      {filteredTodos.length === 0 && totalTodos > 0 ? (
        <p>現在のフィルターに一致するTODOがありません。</p>
      ) : filteredTodos.length === 0 ? (
        <p>まだTODOがありません!上記から追加してください。</p>
      ) : (
        <ul>
          {filteredTodos.map(todo => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={toggleTodo}
              onDelete={deleteTodo}
            />
          ))}
        </ul>
      )}
    </div>
  );
}

💡将来の読み手を考慮した設計の考察

この章で実践した設計原則:

  • 自己説明的なコード: 変数名、関数名、型名が意図を明確に表現
  • 包括的なドキュメント: JSDocコメントでAPI仕様を明確化
  • 実用的なテスト: 実際の使用シナリオに基づいたテストケース
  • 段階的な抽象化: 将来の拡張に備えた柔軟な設計
  • 型安全性: TypeScriptの型システムを活用した堅牢性

TODOリストの更新

  • 混合タイプの処理(削除機能)
  • フィルタリング機能
  • 抽象化の実践
  • 将来の保守性を考慮したテスト設計

🚀コミットとCIの確認

git add . git commit -m 'feat: Add filtering functionality with future-proof design (Chapter 16)' git push

次の章では、TODOアプリの全体を振り返り、TDD実践の学習成果をまとめます。