← トップページに戻る

TDD実践ガイド

💭第7章 疑念をテストに翻訳する(異なるタイプの比較、TypeScript版)

開発を進める中で「これで本当に大丈夫だろうか?」という疑念が生まれることがあります。この章では、そうした疑念をテストコードに翻訳し、確信を得る方法を学びます。

頭の中にある悩みをテストとして表現した。 完璧ではないものの、まずまずのやり方(getClass)でテストを通した。 さらなる設計は、本当に必要になるときまで先延ばしにすることにした。

感情をテストに翻訳することは、TDDの大きなテーマの1つだ。今回は副作用への不快感を、同じDollarに何回も掛け算を行うテストとして表現した。
— Kent Beck『テスト駆動開発』第7章「疑念をテストに翻訳する」より

TODOリスト

  • 異なるタイプのアイテム間の比較について疑念を解決する
  • ItemManagerの型安全性に関する不安を検証する
  • 将来の拡張性についての疑念をテストで確認する

🤔1. 最初の疑念:アイテム管理での型混在

ItemManagerクラスを実装しましたが、異なる型のアイテム(TodoItemとTaskItem)を混在させて管理することに対して疑念が生まれています。

現在の疑念

  • • TodoItemとTaskItemが同じコレクションに入っている時、予期しない動作をしないか?
  • • 型の判定やキャストが正しく動作するか?
  • • パフォーマンスに問題はないか?

🔴2. 疑念をテストに翻訳する

疑念を具体的なテストケースに変換しましょう。

lib/typeConversion.test.ts
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';
import { ItemManager } from './itemManager';
import { BaseItem } from './baseItem';

describe('型変換と混在に関する疑念のテスト', () => {
  describe('混在型コレクション', () => {
    it('配列操作で混在型を適切に処理すること', () => {
      const items: BaseItem[] = [
        TodoItem.create(1, 'TDDを学ぶ'),
        TaskItem.create(2, 'PRをレビューする', 'high'),
        TodoItem.create(3, 'ドキュメントを書く'),
        TaskItem.create(4, 'バグを修正する', 'low')
      ];

      // 疑念:異なる型が混在したときの操作は安全か?
      const completedItems = items.map(item => item.complete());
      
      expect(completedItems).toHaveLength(4);
      expect(completedItems.every(item => item.isCompleted())).toBe(true);
      
      // 各アイテムが正しい型を維持しているか
      expect(completedItems[0]).toBeInstanceOf(TodoItem);
      expect(completedItems[1]).toBeInstanceOf(TaskItem);
      expect(completedItems[2]).toBeInstanceOf(TodoItem);
      expect(completedItems[3]).toBeInstanceOf(TaskItem);
    });

    it('型によるフィルタリングが安全に動作すること', () => {
      const items: BaseItem[] = [
        TodoItem.create(1, 'TODO 1'),
        TaskItem.create(2, 'タスク 1', 'high'),
        TodoItem.create(3, 'TODO 2'),
        TaskItem.create(4, 'タスク 2', 'low')
      ];

      // 疑念:型フィルタリングは正確に動作するか?
      const todos = items.filter((item): item is TodoItem => item instanceof TodoItem);
      const tasks = items.filter((item): item is TaskItem => item instanceof TaskItem);

      expect(todos).toHaveLength(2);
      expect(tasks).toHaveLength(2);
      
      // 型安全性の確認:TypeScriptが正しい型を推論しているか
      todos.forEach(todo => {
        expect(typeof todo.id).toBe('number');
        expect(typeof todo.text).toBe('string');
        // TaskItem特有のメソッドは呼べないはず(コンパイルエラーになる)
      });

      tasks.forEach(task => {
        expect(typeof task.getPriority()).toBe('string');
        expect(task.isHighPriority()).toBeDefined();
      });
    });
  });

  describe('型変換のエッジケース', () => {
    it('空のコレクションを適切に処理すること', () => {
      const emptyItems: BaseItem[] = [];
      
      const todos = emptyItems.filter((item): item is TodoItem => item instanceof TodoItem);
      const tasks = emptyItems.filter((item): item is TaskItem => item instanceof TaskItem);
      
      expect(todos).toHaveLength(0);
      expect(tasks).toHaveLength(0);
    });

    it('単一型のコレクションを処理すること', () => {
      const onlyTodos: BaseItem[] = [
        TodoItem.create(1, 'TODO 1'),
        TodoItem.create(2, 'TODO 2')
      ];

      const tasks = onlyTodos.filter((item): item is TaskItem => item instanceof TaskItem);
      expect(tasks).toHaveLength(0);
    });
  });
});

🟢3. ItemManagerの拡張と疑念の解決

ItemManagerを拡張し、型安全性への疑念を解決します。

lib/itemManager.ts(拡張版)
import { BaseItem } from './baseItem';
import { TodoItem } from './todoItem';
import { TaskItem, Priority } from './taskItem';

export interface ItemStatistics {
  total: number;
  completed: number;
  incomplete: number;
  todos: number;
  tasks: number;
  highPriorityTasks: number;
}

export class ItemManager {
  private items: BaseItem[] = [];

  addItem(item: BaseItem): void {
    this.items.push(item);
  }

  addItems(items: BaseItem[]): void {
    this.items.push(...items);
  }

  removeItem(itemId: number): boolean {
    const initialLength = this.items.length;
    this.items = this.items.filter(item => item.id !== itemId);
    return this.items.length < initialLength;
  }

  completeItem(itemId: number): boolean {
    const itemIndex = this.items.findIndex(item => item.id === itemId);
    if (itemIndex === -1) return false;

    this.items[itemIndex] = this.items[itemIndex].complete();
    return true;
  }

  toggleItem(itemId: number): boolean {
    const itemIndex = this.items.findIndex(item => item.id === itemId);
    if (itemIndex === -1) return false;

    this.items[itemIndex] = this.items[itemIndex].toggle();
    return true;
  }

  // 型安全なゲッターメソッド
  getAllItems(): readonly BaseItem[] {
    return [...this.items];
  }

  getTodos(): TodoItem[] {
    return this.items.filter((item): item is TodoItem => item instanceof TodoItem);
  }

  getTasks(): TaskItem[] {
    return this.items.filter((item): item is TaskItem => item instanceof TaskItem);
  }

  getCompletedItems(): BaseItem[] {
    return this.items.filter(item => item.isCompleted());
  }

  getIncompleteItems(): BaseItem[] {
    return this.items.filter(item => item.isIncomplete());
  }

  getTasksByPriority(priority: Priority): TaskItem[] {
    return this.getTasks().filter(task => task.getPriority() === priority);
  }

  getHighPriorityTasks(): TaskItem[] {
    return this.getTasksByPriority('high');
  }

  // 統計情報の取得
  getStatistics(): ItemStatistics {
    const todos = this.getTodos();
    const tasks = this.getTasks();
    
    return {
      total: this.items.length,
      completed: this.getCompletedItems().length,
      incomplete: this.getIncompleteItems().length,
      todos: todos.length,
      tasks: tasks.length,
      highPriorityTasks: this.getHighPriorityTasks().length
    };
  }

  // 検索機能
  findByText(searchText: string): BaseItem[] {
    const lowerSearchText = searchText.toLowerCase();
    return this.items.filter(item => 
      item.text.toLowerCase().includes(lowerSearchText)
    );
  }

  findById(id: number): BaseItem | undefined {
    return this.items.find(item => item.id === id);
  }

  // バッチ操作
  completeAllTasks(): void {
    this.items = this.items.map(item => 
      item instanceof TaskItem ? item.complete() : item
    );
  }

  completeAllTodos(): void {
    this.items = this.items.map(item => 
      item instanceof TodoItem ? item.complete() : item
    );
  }

  toString(): string {
    return this.items.map(item => item.toString()).join('\n');
  }

  clear(): void {
    this.items = [];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

🔵4. 拡張されたItemManagerのテスト

lib/itemManagerExtended.test.ts
import { ItemManager } from './itemManager';
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';

describe('ItemManagerの拡張機能テスト', () => {
  let manager: ItemManager;

  beforeEach(() => {
    manager = new ItemManager();
  });

  describe('バッチ操作に関する疑念', () => {
    it('TODOを保持しながらすべてのタスクを完了すること', () => {
      // 疑念:バッチ操作が意図した型にのみ影響するか?
      manager.addItems([
        TodoItem.create(1, 'TODO 1'),
        TaskItem.create(2, 'タスク 1', 'high'),
        TodoItem.create(3, 'TODO 2'), 
        TaskItem.create(4, 'タスク 2', 'low')
      ]);

      manager.completeAllTasks();

      const todos = manager.getTodos();
      const tasks = manager.getTasks();

      expect(todos.every(todo => !todo.isCompleted())).toBe(true);
      expect(tasks.every(task => task.isCompleted())).toBe(true);
    });
  });

  describe('統計情報の正確性に関する疑念', () => {
    it('混在コレクションで正確な統計情報を提供すること', () => {
      // 疑念:統計情報が正確に計算されるか?
      manager.addItems([
        TodoItem.create(1, 'TODO 1'),
        TaskItem.create(2, '高優先度タスク', 'high'),
        TodoItem.createCompleted(3, '完了済みTODO'),
        TaskItem.create(4, '低優先度タスク', 'low'),
        TaskItem.createCompleted(5, '完了済み高優先度タスク', 'high')
      ]);

      const stats = manager.getStatistics();

      expect(stats.total).toBe(5);
      expect(stats.completed).toBe(2);
      expect(stats.incomplete).toBe(3);
      expect(stats.todos).toBe(2);
      expect(stats.tasks).toBe(3);
      expect(stats.highPriorityTasks).toBe(2);
    });
  });

  describe('検索機能に関する疑念', () => {
    it('異なるアイテムタイプ間で安全に検索すること', () => {
      // 疑念:検索機能が全ての型で正しく動作するか?
      manager.addItems([
        TodoItem.create(1, 'TypeScriptの基礎を学ぶ'),
        TaskItem.create(2, 'TypeScript上級編を学ぶ', 'high'),
        TodoItem.create(3, 'JavaScriptを練習する'),
        TaskItem.create(4, 'TypeScriptプロジェクトのセットアップ', 'medium')
      ]);

      const results = manager.findByText('TypeScript');
      
      expect(results).toHaveLength(3);
      expect(results.some(item => item instanceof TodoItem)).toBe(true);
      expect(results.some(item => item instanceof TaskItem)).toBe(true);
    });
  });
});

🔮5. 将来の拡張性への疑念

新しいアイテムタイプ(例:リマインダーアイテム)を追加することを想定したテストを書きます。

lib/reminderItem.ts(仮想的な新しいタイプ)
import { BaseItem, ItemId, ItemText } from './baseItem';

export class ReminderItem extends BaseItem {
  private readonly _dueDate: Date;

  constructor(id: ItemId, text: ItemText, dueDate: Date, completed: boolean = false) {
    super(id, text, completed);
    this._dueDate = dueDate;
  }

  static create(id: ItemId, text: ItemText, dueDate: Date): ReminderItem {
    return new ReminderItem(id, text, dueDate, false);
  }

  getDueDate(): Date { return this._dueDate; }
  
  isOverdue(): boolean {
    return !this._completed && this._dueDate < new Date();
  }

  toggle(): ReminderItem {
    return new ReminderItem(this._id, this._text, this._dueDate, !this._completed);
  }

  complete(): ReminderItem {
    return new ReminderItem(this._id, this._text, this._dueDate, true);
  }

  markIncomplete(): ReminderItem {
    return new ReminderItem(this._id, this._text, this._dueDate, false);
  }

  withDueDate(dueDate: Date): ReminderItem {
    return new ReminderItem(this._id, this._text, dueDate, this._completed);
  }

  toString(): string {
    const status = this._completed ? '✓' : '○';
    const dateStr = this._dueDate.toLocaleDateString();
    const overdueMarker = this.isOverdue() ? '⚠️' : '';
    return `${status} 📅 #${this._id}: ${this._text} (${dateStr}) ${overdueMarker}`;
  }
}
lib/extensibility.test.ts
import { ItemManager } from './itemManager';
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';
import { ReminderItem } from './reminderItem';

describe('拡張性に関する疑念のテスト', () => {
  it('新しいアイテムタイプを追加しても既存機能が動作すること', () => {
    // 疑念:新しいアイテムタイプを追加しても既存機能は動作するか?
    const manager = new ItemManager();
    
    const tomorrow = new Date();
    tomorrow.setDate(tomorrow.getDate() + 1);
    
    manager.addItems([
      TodoItem.create(1, 'TODOアイテム'),
      TaskItem.create(2, 'タスクアイテム', 'high'),
      ReminderItem.create(3, 'リマインダーアイテム', tomorrow)
    ]);

    // 基本操作が全ての型で動作することを確認
    expect(manager.getAllItems()).toHaveLength(3);
    
    // 型フィルタリングが既存型に影響しないことを確認
    expect(manager.getTodos()).toHaveLength(1);
    expect(manager.getTasks()).toHaveLength(1);
    
    // 新しい型のフィルタリング
    const reminders = manager.getAllItems().filter(
      (item): item is ReminderItem => item instanceof ReminderItem
    );
    expect(reminders).toHaveLength(1);
    expect(reminders[0].getDueDate()).toEqual(tomorrow);
  });

  it('新しい型でもポリモーフィックな動作を維持すること', () => {
    // 疑念:ポリモーフィズムが新しい型でも正しく動作するか?
    const manager = new ItemManager();
    
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    
    manager.addItems([
      ReminderItem.create(1, 'Overdue reminder', yesterday),
      ReminderItem.create(2, 'Future reminder', new Date(Date.now() + 86400000))
    ]);

    // ポリモーフィックな操作
    manager.completeItem(1);
    
    const items = manager.getAllItems();
    expect(items[0].isCompleted()).toBe(true);
    expect(items[1].isCompleted()).toBe(false);
    
    // 型固有の機能
    const reminders = items.filter((item): item is ReminderItem => item instanceof ReminderItem);
    expect(reminders[0].isOverdue()).toBe(false); // 完了済みなのでオーバーデューではない
    expect(reminders[1].isOverdue()).toBe(false); // 期限前
  });
});

6. パフォーマンスへの疑念

lib/performanceConcerns.test.ts
import { ItemManager } from './itemManager';
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';

describe('パフォーマンスに関する疑念のテスト', () => {
  it('大量の混在コレクションを効率的に処理すること', () => {
    // 疑念:大量の異なる型のアイテムでもパフォーマンスは維持されるか?
    const manager = new ItemManager();
    const start = performance.now();

    // 10,000個の混合アイテムを作成
    const items = [];
    for (let i = 0; i < 10000; i++) {
      if (i % 3 === 0) {
        items.push(TodoItem.create(i, `TODO ${i}`));
      } else if (i % 3 === 1) {
        items.push(TaskItem.create(i, `タスク ${i}`, 'medium'));
      } else {
        items.push(TaskItem.create(i, `高優先度タスク ${i}`, 'high'));
      }
    }

    manager.addItems(items);
    const addTime = performance.now() - start;

    // 操作のパフォーマンステスト
    const opStart = performance.now();
    
    const stats = manager.getStatistics();
    const highPriorityTasks = manager.getHighPriorityTasks();
    
    const opTime = performance.now() - opStart;

    expect(stats.total).toBe(10000);
    expect(addTime).toBeLessThan(50); // 50ms以内での追加
    expect(opTime).toBeLessThan(100); // 100ms以内での操作
    expect(highPriorityTasks.length).toBeGreaterThan(0);
  });

  it('継承階層での等価性チェックを効率的に維持すること', () => {
    // 疑念:継承階層での等価性チェックのパフォーマンスは問題ないか?
    const todo1 = TodoItem.create(1, 'テスト');
    const todo2 = TodoItem.create(1, 'テスト');
    const task = TaskItem.create(1, 'テスト', 'high');

    const start = performance.now();

    for (let i = 0; i < 100000; i++) {
      todo1.equals(todo2); // 同じ型
      todo1.equals(task);  // 異なる型
    }

    const duration = performance.now() - start;
    expect(duration).toBeLessThan(100); // 100ms以内
  });
});

🚀コミットとCIの確認

git add .
git commit -m "feat: Translate concerns into tests and extend ItemManager functionality"
git push

🎯この章のまとめ

この章では以下を学びました:

  • 疑念の具体化:漠然とした不安を具体的なテストケースに翻訳
  • 型安全性の検証:TypeScriptの型システムと実行時の動作の整合性確認
  • 拡張性のテスト:将来の機能追加に対する準備とテスト
  • パフォーマンス懸念の解決:性能に関する疑念を測定可能なテストで検証
  • 設計判断の先延ばし:必要になるまで複雑な設計を避ける判断

TDDにおいて「疑念をテストに翻訳する」ことは、設計上の不安を具体的に解決し、自信を持って開発を進めるための重要な技術です。この手法により、推測ではなく事実に基づいた開発を行うことができます。

これで第1編:基礎編の第2章〜第7章が完了しました。次のステップでは、これらの基礎を元により複雑なTODOアプリケーションの機能を構築していくことになります。