← トップページに戻る

TDD実践ガイド

🧹第11章 不要になったら消す(重複の除去)

重複は設計の悪臭です。この章では、TODOアプリにおける重複コードを発見し、 段階的に除去していきます。クリーンで保守性の高いコードを目指します。

「2つのサブクラス`Dollar`と`Franc`にはもうコンストラクタしか残っていない。 コンストラクタだけのサブクラスを残しておく理由はないので、サブクラスを消してしまいたい。」

— Kent Beck『テスト駆動開発』より

TODOリスト

  • 重複コードの発見と除去
  • `IncompleteTodoItem`と`CompletedTodoItem`の統合
  • React Componentの共通化
  • 不要なテストの削除

🔍重複の発見

現在のTODOアプリには、以下の重複が存在しています:

クラスの重複

`IncompleteTodoItem`と`CompletedTodoItem`の類似実装

UIの重複

React コンポーネント間での重複ロジック

テストの重複

重複するテストケース

🔬`TodoItem`サブクラスの重複分析

現在の実装を確認してみましょう:

現在の重複実装
// IncompleteTodoItem と CompletedTodoItem の重複部分
class IncompleteTodoItem extends TodoItem {
  constructor(id: number, text: string, category: TodoCategory) {
    super(id, text, false, category); // completed は false
  }

  complete(): ITodoItem {
    return TodoItem.createCompleted(this.id, this.text, this.category);
  }
}

class CompletedTodoItem extends TodoItem {
  constructor(id: number, text: string, category: TodoCategory) {
    super(id, text, true, category); // completed は true
  }

  complete(): ITodoItem {
    return this; // 既に完了済み
  }
}

分析結果:2つのクラスの違いは`completed`フィールドの値と`complete()`メソッドの実装だけです。

🔴1. レッド:統合されたTodoItemのテストを書く

まず、サブクラスを使わない統一されたテストを書きます。

lib/TodoItem.test.ts
describe('統一されたTodoItem実装のテスト', () => {
  it('特定の完了状態でTODOを作成すること', () => {
    const incompleteTodo = TodoItem.create(1, 'TDDを学ぶ', 'LEARNING', false);
    const completedTodo = TodoItem.create(2, 'テストを書く', 'WORK', true);
    
    expect(incompleteTodo.completed).toBe(false);
    expect(completedTodo.completed).toBe(true);
  });

  it('完了ロジックを統一的に処理すること', () => {
    const todo = TodoItem.create(1, 'タスク', 'WORK', false);
    const completedTodo = todo.complete();
    
    expect(completedTodo.completed).toBe(true);
    expect(completedTodo.id).toBe(todo.id);
    expect(completedTodo.text).toBe(todo.text);
    expect(completedTodo.category).toBe(todo.category);
  });

  it('既に完了済みの場合は完了状態を維持すること', () => {
    const completedTodo = TodoItem.create(1, '完了済み', 'WORK', true);
    const stillCompleted = completedTodo.complete();
    
    expect(stillCompleted).toBe(completedTodo); // 同じインスタンス
  });
});

🟢2. グリーン:統一されたTodoItem実装

サブクラスを削除し、単一のクラスで実装します。

lib/TodoItem.ts (統一版)
type TodoCategory = 'WORK' | 'PERSONAL' | 'SHOPPING' | 'LEARNING';

export class TodoItem implements ITodoItem {
  constructor(
    public readonly id: number,
    public readonly text: string,
    public readonly completed: boolean,
    public readonly category: TodoCategory
  ) {}

  complete(): ITodoItem {
    if (this.completed) {
      return this; // 既に完了済み
    }
    return new TodoItem(this.id, this.text, true, this.category);
  }

  multiply(count: number): ITodoItem[] {
    if (count <= 0) return [];
    
    const result: ITodoItem[] = [];
    for (let i = 0; i < count; i++) {
      result.push(new TodoItem(
        this.id + i, 
        this.text, 
        this.completed, // 完了状態を保持
        this.category
      ));
    }
    return result;
  }

  equals(other: ITodoItem): boolean {
    return this.id === other.id && 
           this.text === other.text && 
           this.completed === other.completed &&
           this.category === other.category;
  }

  // 統一されたFactory Methods
  static create(
    id: number, 
    text: string, 
    category: TodoCategory = 'PERSONAL',
    completed: boolean = false
  ): ITodoItem {
    return new TodoItem(id, text, completed, category);
  }

  // カテゴリー別Factory Methods(簡潔化)
  static work(id: number, text: string, completed = false): ITodoItem {
    return TodoItem.create(id, text, 'WORK', completed);
  }

  static personal(id: number, text: string, completed = false): ITodoItem {
    return TodoItem.create(id, text, 'PERSONAL', completed);
  }

  static shopping(id: number, text: string, completed = false): ITodoItem {
    return TodoItem.create(id, text, 'SHOPPING', completed);
  }

  static learning(id: number, text: string, completed = false): ITodoItem {
    return TodoItem.create(id, text, 'LEARNING', completed);
  }
}

// サブクラスは削除済み ✅

🔵3. 重複するテストの削除

サブクラスの削除により、いくつかのテストが冗長になりました。

削除対象のテスト

削除対象のテスト
// 削除: サブクラス固有のテスト
describe('IncompleteTodoItem specific behavior', () => {
  // このテストブロック全体を削除
});

describe('CompletedTodoItem specific behavior', () => {
  // このテストブロック全体を削除
});

残すべきテスト

残すべきテスト
describe('TodoItem', () => {
  it('デフォルトで未完了のTODOを作成すること', () => {
    const todo = TodoItem.work(1, 'コードレビュー');
    expect(todo.completed).toBe(false);
  });

  it('complete()呼び出し時にTODOが完了すること', () => {
    const todo = TodoItem.personal(1, '食材を買う');
    const completed = todo.complete();
    
    expect(completed.completed).toBe(true);
    expect(completed.id).toBe(todo.id);
  });
});

⚛️4. React Component の重複除去

現在、複数のコンポーネントで重複ロジックがあります。共通化を進めます。

重複の発見

`TodoItemComponent.tsx`と`TodoListComponent.tsx`でカテゴリースタイルが重複

components/CategoryBadge.tsx
"use client";

import React from 'react';

type TodoCategory = 'WORK' | 'PERSONAL' | 'SHOPPING' | 'LEARNING';

interface CategoryBadgeProps {
  category: TodoCategory;
  className?: string;
}

const categoryStyles: Record<TodoCategory, string> = {
  WORK: 'bg-red-100 text-red-800',
  PERSONAL: 'bg-green-100 text-green-800',
  SHOPPING: 'bg-yellow-100 text-yellow-800',
  LEARNING: 'bg-blue-100 text-blue-800'
};

export function CategoryBadge({ category, className = '' }: CategoryBadgeProps) {
  return (
    <span className={`px-2 py-1 text-xs rounded-full ${categoryStyles[category]} ${className}`}>
      {category}
    </span>
  );
}
components/TodoItemComponent.tsx
import { ITodoItem } from '@/lib/TodoItem';
import { CategoryBadge } from './CategoryBadge';

export function TodoItemComponent({ todo, onToggle }) {
  return (
    <li className="flex items-center space-x-3 p-3 border rounded-lg">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        className="w-4 h-4 text-blue-600"
      />
      <span className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
        {todo.text}
      </span>
      <CategoryBadge category={todo.category} />
    </li>
  );
}

🪝5. カスタムフックでの状態管理重複除去

hooks/useTodoList.ts
"use client";

import { useState, useCallback } from 'react';
import { ITodoItem, TodoItem } from '@/lib/TodoItem';

export function useTodoList(initialTodos: ITodoItem[] = []) {
  const [todos, setTodos] = useState<ITodoItem[]>(initialTodos);

  const toggleTodo = useCallback((id: number) => {
    setTodos(prev => 
      prev.map(todo => 
        todo.id === id ? todo.complete() : todo
      )
    );
  }, []);

  const addTodo = useCallback((text: string, category: string = 'PERSONAL') => {
    const newId = Math.max(0, ...todos.map(t => t.id)) + 1;
    const newTodo = TodoItem.create(newId, text, category as any);
    setTodos(prev => [...prev, newTodo]);
  }, [todos]);

  const removeTodo = useCallback((id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);

  return {
    todos,
    toggleTodo,
    addTodo,
    removeTodo
  };
}

🏭6. 不要なFactory Methodの削除

一部のFactory Methodが重複していることがわかりました。最適化を行います。

削除前(重複あり)

削除前(重複あり)
// 重複するメソッド
static create(...) { ... }
static createCompleted(...) { ... }  // ← 削除対象
static work(...) { ... }
static personal(...) { ... }

削除後(最適化)

削除後(最適化)
// 基本Factory Method
static create(
  id: number, 
  text: string, 
  category: TodoCategory = 'PERSONAL',
  completed: boolean = false
): ITodoItem {
  return new TodoItem(id, text, completed, category);
}

// カテゴリー別Factory Methods (createのwrapper)
static work(id: number, text: string, completed = false): ITodoItem {
  return TodoItem.create(id, text, 'WORK', completed);
}

// createCompletedは削除 - createで代用可能

🚀7. Next.js App Router でのクリーンな実装

app/clean-todos/page.tsx
"use client";

import React from 'react';
import { useTodoList } from '@/hooks/useTodoList';
import { TodoItem } from '@/lib/TodoItem';
import { TodoItemComponent } from '@/components/TodoItemComponent';

const initialTodos = [
  TodoItem.work(1, 'コードレビュー'),
  TodoItem.learning(2, 'TDD設計パターン習得'),
  TodoItem.personal(3, '健康管理', true), // 完了済み
];

export default function CleanTodosPage() {
  const { todos, toggleTodo, addTodo } = useTodoList(initialTodos);

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Clean TODO List</h1>
      <p className="text-gray-600 mb-6">
        重複を除去したクリーンな実装
      </p>
      
      <ul className="space-y-2">
        {todos.map((todo) => (
          <TodoItemComponent 
            key={todo.id} 
            todo={todo} 
            onToggle={toggleTodo}
          />
        ))}
      </ul>

      <button
        onClick={() => addTodo('新しいタスク', 'WORK')}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        作業タスクを追加
      </button>
    </main>
  );
}

重複除去の成果

この章で除去した重複:

クラス構造

2つのサブクラスを統一クラスに統合

テストコード

冗長なテストケースを削除

UI コンポーネント

共通スタイルを`CategoryBadge`に抽出

状態管理

カスタムフックで重複ロジックを統合

Factory Methods

重複するメソッドを整理

TODOリストの更新

  • 重複コードの発見と除去
  • `IncompleteTodoItem`と`CompletedTodoItem`の統合
  • React Componentの共通化
  • 不要なテストの削除

🎯まとめ

重複コードの発見と除去を通じて、より保守しやすいクリーンなコードを実現しました。 サブクラスの統合、テストの整理、UI コンポーネントの共通化により、 システム全体の複雑性が大幅に削減されました。 次の章では、「設計とメタファー」を学び、 TODOリストの概念設計とドメインモデルの構築を通じて、 より洗練された設計手法を身につけます。