← トップページに戻る

TDD実践ガイド

🔍第6章 テスト不足に気づいたら(等価性の一般化、TypeScript版)

リファクタリングを進める中で、「十分なテストが書かれていない」と気づくことがあります。この章では、そのような状況での対処法と、等価性の一般化について学びます。

十分な量のテストが無い場合、テストによって守られていないリファクタリングを行わざるを得ない。リファクタリングの過程でミスがあっても、テストは通り続けるかもしれない。では、どうするべきだろうか。

そういう場合は、あればよかったと思うテストを書こう。書かなければ、コードはリファクタリングの過程でいつか壊れてしまう。
— Kent Beck『テスト駆動開発』第6章「テスト不足に気づいたら」より

TODOリスト

  • BaseItemクラスの等価性を正しくテストする
  • 継承関係での等価性判定を改善する
  • テスト不足のリスクを特定して対処する

⚠️1. 問題の発見:テスト不足

前章でBaseItemクラスを導入し、TodoItemとTaskItemの重複を除去しました。しかし、リファクタリングの過程で、等価性の実装に関するテストが不足していることに気づきました。

現在の等価性テストを見直してみましょう:

問題のあるテストの例
// これらのテストでは不十分
it('IDとテキストが同じ場合は等価であること', () => {
  const todo1 = TodoItem.create(1, 'TDDを学ぶ');
  const todo2 = TodoItem.create(1, 'TDDを学ぶ');
  expect(todo1.equals(todo2)).toBe(true);
});

このテストでは以下の重要なケースが抜けています:

抜けているテストケース

  • • nullとの比較
  • • 異なる型のオブジェクトとの比較
  • • BaseItemとサブクラスの比較
  • • 等価性の対称性
  • • 等価性の推移性

🔴2. 不足しているテストの特定と追加

まず、足りないテストケースを洗い出し、テストを追加します。

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

describe('等価性の包括的テスト', () => {
  describe('TodoItemの等価性', () => {
    it('IDとテキストが同じ場合は等価であること', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.create(1, 'TDDを学ぶ');
      expect(todo1.equals(todo2)).toBe(true);
    });

    it('IDが異なる場合は等価でないこと', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.create(2, 'TDDを学ぶ');
      expect(todo1.equals(todo2)).toBe(false);
    });

    it('テキストが異なる場合は等価でないこと', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.create(1, 'アプリを構築する');
      expect(todo1.equals(todo2)).toBe(false);
    });

    it('完了状態に関係なく等価であること', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.createCompleted(1, 'TDDを学ぶ');
      expect(todo1.equals(todo2)).toBe(true);
    });

    // 新しく追加するテストケース
    it('同じIDとテキストでもTaskItemとは等価でないこと', () => {
      const todo = TodoItem.create(1, 'TDDを学ぶ');
      const task = TaskItem.create(1, 'TDDを学ぶ');
      expect(todo.equals(task)).toBe(false);
    });

    it('nullとは等価でないこと', () => {
      const todo = TodoItem.create(1, 'TDDを学ぶ');
      expect(todo.equals(null as any)).toBe(false);
    });

    it('BaseItemでないオブジェクトとは等価でないこと', () => {
      const todo = TodoItem.create(1, 'TDDを学ぶ');
      const plainObject = { id: 1, text: 'TDDを学ぶ' };
      expect(todo.equals(plainObject as any)).toBe(false);
    });

    it('反射性を満たすこと(a.equals(a)はtrueであること)', () => {
      const todo = TodoItem.create(1, 'TDDを学ぶ');
      expect(todo.equals(todo)).toBe(true);
    });

    it('対称性を満たすこと(a.equals(b) === b.equals(a))', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.create(1, 'TDDを学ぶ');
      expect(todo1.equals(todo2)).toBe(todo2.equals(todo1));
    });

    it('推移性を満たすこと(a.equals(b) && b.equals(c)ならa.equals(c))', () => {
      const todo1 = TodoItem.create(1, 'TDDを学ぶ');
      const todo2 = TodoItem.create(1, 'TDDを学ぶ');
      const todo3 = TodoItem.create(1, 'TDDを学ぶ');
      
      expect(todo1.equals(todo2)).toBe(true);
      expect(todo2.equals(todo3)).toBe(true);
      expect(todo1.equals(todo3)).toBe(true);
    });
  });

  describe('TaskItemの等価性', () => {
    it('IDとテキストが同じなら優先度に関係なく等価であること', () => {
      const task1 = TaskItem.create(1, 'コードレビューを行う', 'high');
      const task2 = TaskItem.create(1, 'コードレビューを行う', 'low');
      expect(task1.equals(task2)).toBe(true);
    });

    it('同じIDとテキストでもTodoItemとは等価でないこと', () => {
      const task = TaskItem.create(1, 'TDDを学ぶ');
      const todo = TodoItem.create(1, 'TDDを学ぶ');
      expect(task.equals(todo)).toBe(false);
    });

    it('nullとの比較を適切に処理すること', () => {
      const task = TaskItem.create(1, 'コードレビューを行う');
      expect(task.equals(null as any)).toBe(false);
    });
  });
});

🧪3. テスト実行と失敗の確認

新しく追加したテストを実行して、現在の実装の問題点を確認します。

npm test

失敗の原因を分析します:

null比較の処理

equalsメソッドがnullを適切に処理していない

型チェックの不備

非BaseItemオブジェクトとの比較で問題が発生

継承関係の問題

BaseItemとサブクラスの等価性判定ロジックに問題

🟢4. BaseItemクラスの等価性実装の改善

lib/baseItem.ts(改良版)
export type ItemId = number;
export type ItemText = string;

export abstract class BaseItem {
  protected readonly _id: ItemId;
  protected readonly _text: ItemText;
  protected readonly _completed: boolean;

  constructor(id: ItemId, text: ItemText, completed: boolean = false) {
    this._id = id;
    this._text = text;
    this._completed = completed;
  }

  get id(): ItemId { return this._id; }
  get text(): ItemText { return this._text; }
  get completed(): boolean { return this._completed; }

  isCompleted(): boolean { return this._completed; }
  isIncomplete(): boolean { return !this._completed; }

  // 抽象メソッド
  abstract toggle(): BaseItem;
  abstract complete(): BaseItem;
  abstract markIncomplete(): BaseItem;
  abstract toString(): string;

  // 改良された等価性判定
  equals(other: unknown): boolean {
    // null, undefined チェック
    if (other == null) {
      return false;
    }

    // 型チェック
    if (!(other instanceof BaseItem)) {
      return false;
    }

    // 具象クラスの型が同じかチェック
    if (this.constructor !== other.constructor) {
      return false;
    }

    // 基本的な等価性判定
    return this.baseEquals(other);
  }

  // 共通の等価性判定ロジック
  protected baseEquals(other: BaseItem): boolean {
    return this._id === other._id && this._text === other._text;
  }
}

🔵5. サブクラスでの等価性実装の簡素化

BaseItemで等価性の基本ロジックを実装したので、サブクラスは簡素化できます。

lib/todoItem.ts(簡素化版)
import { BaseItem, ItemId, ItemText } from './baseItem';

export type TodoId = ItemId;
export type TodoText = ItemText;

export class TodoItem extends BaseItem {
  constructor(id: TodoId, text: TodoText, completed: boolean = false) {
    super(id, text, completed);
  }

  static create(id: TodoId, text: TodoText): TodoItem {
    return new TodoItem(id, text, false);
  }

  static createCompleted(id: TodoId, text: TodoText): TodoItem {
    return new TodoItem(id, text, true);
  }

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

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

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

  // equals メソッドは BaseItem から継承されるので削除

  toString(): string {
    const status = this._completed ? '✓' : '○';
    return `${status} #${this._id}: ${this._text}`;
  }
}
lib/taskItem.ts(簡素化版)
import { BaseItem, ItemId, ItemText } from './baseItem';

export type Priority = 'low' | 'medium' | 'high';
export type TaskId = ItemId;
export type TaskText = ItemText;

export class TaskItem extends BaseItem {
  private readonly _priority: Priority;

  constructor(id: TaskId, text: TaskText, priority: Priority = 'medium', completed: boolean = false) {
    super(id, text, completed);
    this._priority = priority;
  }

  static create(id: TaskId, text: TaskText, priority: Priority = 'medium'): TaskItem {
    return new TaskItem(id, text, priority, false);
  }

  static createCompleted(id: TaskId, text: TaskText, priority: Priority = 'medium'): TaskItem {
    return new TaskItem(id, text, priority, true);
  }

  getPriority(): Priority { return this._priority; }
  isHighPriority(): boolean { return this._priority === 'high'; }
  isMediumPriority(): boolean { return this._priority === 'medium'; }
  isLowPriority(): boolean { return this._priority === 'low'; }

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

  complete(): TaskItem {
    return new TaskItem(this._id, this._text, this._priority, true);
  }

  markIncomplete(): TaskItem {
    return new TaskItem(this._id, this._text, this._priority, false);
  }

  withPriority(priority: Priority): TaskItem {
    return new TaskItem(this._id, this._text, priority, this._completed);
  }

  // equals メソッドは BaseItem から継承されるので削除

  toString(): string {
    const status = this._completed ? '✓' : '○';
    const prioritySymbol = {
      high: '🔴',
      medium: '🟡',
      low: '🟢'
    }[this._priority];
    
    return `${status} ${prioritySymbol} #${this._id}: ${this._text}`;
  }
}

🔬6. エッジケースのテスト追加

さらに詳細なエッジケースをテストします。

lib/edgeCases.test.ts
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';

describe('エッジケースのテスト', () => {
  describe('境界条件', () => {
    it('IDが0の場合を処理すること', () => {
      const todo = TodoItem.create(0, 'IDが0のテスト');
      expect(todo.id).toBe(0);
    });

    it('負のIDを処理すること', () => {
      const todo = TodoItem.create(-1, '負のIDのテスト');
      expect(todo.id).toBe(-1);
    });

    it('空のテキストを処理すること', () => {
      const todo = TodoItem.create(1, '');
      expect(todo.text).toBe('');
    });

    it('非常に長いテキストを処理すること', () => {
      const longText = 'A'.repeat(1000);
      const todo = TodoItem.create(1, longText);
      expect(todo.text).toBe(longText);
    });

    it('テキスト内の特殊文字を処理すること', () => {
      const specialText = 'テスト with 特殊文字 and émojis 🚀';
      const todo = TodoItem.create(1, specialText);
      expect(todo.text).toBe(specialText);
    });
  });

  describe('型安全性', () => {
    it('コレクション内で型安全性を維持すること', () => {
      const items = [
        TodoItem.create(1, 'TODO 1'),
        TaskItem.create(2, 'タスク 1', 'high'),
        TodoItem.create(3, 'TODO 2')
      ];

      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(1);
      expect(tasks[0].getPriority()).toBe('high');
    });
  });

  describe('不変性の検証', () => {
    it('トグル時に元のオブジェクトを変更しないこと', () => {
      const original = TodoItem.create(1, '元のオブジェクト');
      const originalCompleted = original.completed;
      
      const toggled = original.toggle();
      
      expect(original.completed).toBe(originalCompleted);
      expect(toggled.completed).toBe(!originalCompleted);
      expect(original).not.toBe(toggled); // 異なるインスタンス
    });

    it('優先度変更時に元のTaskItemを変更しないこと', () => {
      const original = TaskItem.create(1, 'タスク', 'low');
      const originalPriority = original.getPriority();
      
      const modified = original.withPriority('high');
      
      expect(original.getPriority()).toBe(originalPriority);
      expect(modified.getPriority()).toBe('high');
      expect(original).not.toBe(modified);
    });
  });
});

📊7. テスト不足のリスクを軽減する戦略

lib/testCoverage.test.ts
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';

describe('テストカバレッジの検証', () => {
  describe('すべてのpublicメソッドがテストされること', () => {
    it('TodoItemのすべてのメソッドをテストすること', () => {
      const todo = TodoItem.create(1, 'テスト');
      
      // すべてのpublicメソッドが呼び出されることを確認
      expect(todo.id).toBeDefined();
      expect(todo.text).toBeDefined();
      expect(todo.completed).toBeDefined();
      expect(todo.isCompleted()).toBeDefined();
      expect(todo.isIncomplete()).toBeDefined();
      expect(todo.toggle()).toBeDefined();
      expect(todo.complete()).toBeDefined();
      expect(todo.markIncomplete()).toBeDefined();
      expect(todo.equals(todo)).toBeDefined();
      expect(todo.toString()).toBeDefined();
    });

    it('TaskItemのすべてのメソッドをテストすること', () => {
      const task = TaskItem.create(1, 'テスト', 'high');
      
      // すべてのpublicメソッドが呼び出されることを確認
      expect(task.getPriority()).toBeDefined();
      expect(task.isHighPriority()).toBeDefined();
      expect(task.isMediumPriority()).toBeDefined();
      expect(task.isLowPriority()).toBeDefined();
      expect(task.withPriority('low')).toBeDefined();
    });
  });
});

🚀コミットとCIの確認

git add .
git commit -m "feat: Add comprehensive equality tests and fix test coverage gaps"
git push

🎯この章のまとめ

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

  • テスト不足の発見:リファクタリング中に不十分なテストに気づく方法
  • 遡ってテストを追加:安全なリファクタリングのための事前テスト作成
  • 等価性の完全な実装:null、型安全性、対称性、推移性を考慮した実装
  • エッジケースの洗い出し:境界条件や特殊ケースのテスト
  • テストカバレッジの向上:すべての公開メソッドのテスト確保

次の章では、疑念をテストに翻訳する方法について学びます。