← トップページに戻る

TDD実践ガイド

⚖️第3章 TODOアイテムの等価性とテスト(三角測量、TypeScript版)

TODOアプリ開発において、TODOアイテムの比較は欠かせない機能です。ユーザーが「同じTODOアイテムかどうか」を判断するため、等価性(equality)の実装が必要になります。

任意の無線信号の方向を測定できる2つの受信機の間の距離がわかっている場合、信号の発信地点までの距離と方角を算出できる(読者の皆さんが三角法を覚えているならば、だが)。この算出方法は三角測量と呼ばれている。

三角測量を真似て、コードを一般化できるのは、2つ以上の実例があるときだけだと考えてみよう。
— Kent Beck『テスト駆動開発』第3章「三角測量」より

📐三角測量とは?なぜ複数のテストが必要なのか

🤔 問題:1つのテストだけでは不十分

例えば、「2つの数を足す関数」をテストするとき、最初にこんなテストを書いたとします:

例:不十分なテスト
it('2つの数を足せること', () => {
  expect(add(2, 3)).toBe(5);
});

このテストをパスさせるために、こんな実装をしてしまうかもしれません:

例:ベタ書きの実装
function add(a: number, b: number): number {
  return 5; // テストはパスするが、明らかに間違い
}

❌ この実装は1つのテストはパスしますが、他の入力では確実に失敗します

✅ 解決:複数のテストで「一般化」を強制

2つ目、3つ目のテストを追加すると、ベタ書きでは対応できなくなります:

例:複数のテストケース
it('2つの数を足せること', () => {
  expect(add(2, 3)).toBe(5);
});

it('異なる数でも足せること', () => {
  expect(add(1, 4)).toBe(5);
});

it('さらに異なる数でも足せること', () => {
  expect(add(10, 7)).toBe(17);
});

これらのテストをすべてパスさせるには、本当の加算ロジックを実装するしかありません:

例:一般化された実装
function add(a: number, b: number): number {
  return a + b; // 本当の実装
}

✅ 複数のテストが、正しい一般化された実装を「強制」してくれます

🎯 三角測量の核心

1つのテストだけ → ベタ書きでも通ってしまう(危険)

複数のテスト → 本当のロジックを実装せざるを得ない(安全)

💡 つまり:複数の「観測点」(テストケース)があることで、正しい「位置」(実装)を特定できる

🔄 TDDでの三角測量の流れ

  1. レッド:1つ目のテストを書く(失敗)
  2. グリーン:ベタ書きでもいいからテストをパスさせる
  3. 追加テスト:2つ目、3つ目のテストを書く(失敗)
  4. リファクタリング:すべてのテストをパスする一般化された実装を書く

この流れにより、「過度に複雑でない、でも十分に汎用的な」実装が自然に生まれます

この章では、TODOアイテムの等価性判定をTDDで実装し、「三角測量」という実装戦略を学びます。

📝TODOアイテムの等価性における三角測量

🎯 今回の課題:TODOアイテムはいつ「同じ」なのか?

TODOアプリでは、「2つのTODOアイテムが同じかどうか」を判断する必要があります。でも、何をもって「同じ」とするかは自明ではありません:

  • IDが同じなら同じ?
  • テキストが同じなら同じ?
  • 完了状態も考慮する?
  • 作成日時は関係ある?

⚠️ 1つのテストだけだと危険な理由

最初にこんなテストだけを書いたとします:

危険な例:テストが1つだけ
it('同じTODOアイテムは等価であること', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(1, 'TypeScriptを学ぶ');
  expect(todo1.equals(todo2)).toBe(true);
});

このテストをパスさせるために、こんな実装をしてしまうかもしれません:

危険な実装例
equals(other: TodoItem): boolean {
  return true; // 常にtrueを返す(危険!)
}

問題:この実装では、明らかに異なるTODOアイテム同士も「等価」と判定してしまいます

✅ 三角測量で正しい実装を導く

複数のテストケースを追加することで、正しい実装を「強制」します:

三角測量:複数のテストケース
// 1つ目:同じアイテムは等価
it('IDとテキストが同じ場合は等価であること', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(1, 'TypeScriptを学ぶ');
  expect(todo1.equals(todo2)).toBe(true);
});

// 2つ目:IDが違えば等価でない
it('IDが異なる場合は等価でないこと', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(2, 'TypeScriptを学ぶ');
  expect(todo1.equals(todo2)).toBe(false); // ←これで「常にtrue」は破綻
});

// 3つ目:テキストが違えば等価でない
it('テキストが異なる場合は等価でないこと', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(1, 'Reactを学ぶ');
  expect(todo1.equals(todo2)).toBe(false); // ←さらに制約が厳しくなる
});

これらのテストをすべてパスさせるには、本当のロジックを実装するしかありません:

正しい実装
equals(other: TodoItem): boolean {
  return this.id === other.id && this.text === other.text;
}

結果:複数のテストが、IDとテキストの両方をチェックする正しい実装を自然に導いてくれました

🔍 三角測量が教えてくれること

要件の明確化:テストを書く過程で「本当に必要な条件」が見えてくる

過剰実装の防止:必要最小限の実装に自然と収束する

バグの早期発見:「常にtrue」のような明らかな間違いをすぐに発見できる

設計の改善:複数の観点から考えることで、より良い設計が生まれる

TODOリスト

  • TodoItem同士の等価性比較ができる
  • 異なるTODOアイテムは等価でない
  • IDと内容が同じTODOアイテムは等価

🔴1. レッド:失敗するテストを書く(TODOアイテムの等価性)

まず、TODOアイテムの等価性をテストするファイルを作成します。lib/todoItem.test.ts を作成しましょう。

lib/todoItem.test.ts
import { TodoItem } from './todoItem'; // todoItem.ts はまだ存在しない

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

この時点では lib/todoItem.ts ファイルもTodoItem クラスも存在しないため、このテストは失敗します。 ターミナルでテストを実行してみましょう。

npm test

Jestがエラーを出力し、テストが赤く(失敗)表示されるはずです。これが「レッド」のフェーズです。

🟢2. グリーン:テストをパスさせる最小限のコードを書く

次に、テストをパスさせるための最小限のコードを lib/todoItem.ts に記述します。

lib/todoItem.ts
export class TodoItem {
  constructor(
    public id: number,
    public text: string,
    public completed: boolean = false
  ) {}

  equals(other: TodoItem): boolean {
    // テストをパスさせるための仮実装
    return true; // 常にtrueを返す
  }
}

書籍で言うところの「仮実装」です。

失敗するテストを書いてから、最初に行う実装はどのようなものだろうか――ベタ書きの値を返そう。
— Kent Beck『テスト駆動開発』第28章「仮実装を経て本実装へ」より

再度テストを実行します。

npm test

今度はテストが成功し、緑(成功)で表示されるはずです。これが「グリーン」のフェーズです。 この段階では、コードの品質や汎用性は問いません。とにかくテストをパスさせることが目的です。

📐3. 三角測量の実践:複数のテストで一般化を促す

上で学んだ「三角測量」の概念を実際に適用します。2つ目の実例がより汎用性の高い解を必要とするときのみ、一般化を行います。

異なるTODOアイテムが等価でないことを確認するテストを追加します。

lib/todoItem.test.ts (拡張版)
import { TodoItem } from './todoItem';

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

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

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

テストを実行します。新しいテストが失敗するはずです(レッド)。

npm test

🔵4. リファクタリング:一般化された実装

🤔 どうやって正しい実装を見つけるか?

失敗しているテストを見て、「どんな条件で等価性を判定すべきか」を考えます:

失敗しているテスト
// ❌ 失敗:IDが違うのに等価と判定されてしまう
it('IDが異なる場合は等価でないこと', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(2, 'TypeScriptを学ぶ'); // ID: 2
  expect(todo1.equals(todo2)).toBe(false); // 期待:false、実際:true
});

// ❌ 失敗:テキストが違うのに等価と判定されてしまう
it('テキストが異なる場合は等価でないこと', () => {
  const todo1 = new TodoItem(1, 'TypeScriptを学ぶ');
  const todo2 = new TodoItem(1, 'Reactを学ぶ'); // テキスト違い
  expect(todo1.equals(todo2)).toBe(false); // 期待:false、実際:true
});

💡 分析:IDまたはテキストが異なる場合は「等価でない」と判定する必要がある

✅ 解決策:論理的な等価性判定

すべてのテストをパスさせるには、以下の論理が必要です:

等価 = (ID が同じ) AND (テキストが同じ)

これをTypeScriptで実装すると:

lib/todoItem.ts (一般化版)
export class TodoItem {
  constructor(
    public id: number,
    public text: string,
    public completed: boolean = false
  ) {}

  equals(other: TodoItem): boolean {
    // IDとテキストが同じときのみ等価とする
    return this.id === other.id && this.text === other.text;
  }
}

🔄 実装後の確認

実装を更新したら、テストを実行して確認します:

npm test

すべてのテストが緑(成功)になれば、一般化された実装の完成です!

🤔5. 完了状態の扱い

完了状態(completed)は等価性の判定に含めるべきでしょうか?ビジネス要件に依存しますが、今回は「同じTODOアイテムなら完了状態が違っても等価」と考えることにします。

これをテストで確認しましょう。

lib/todoItem.test.ts (完了状態テスト追加)
  it('完了状態が異なっても等価であること', () => {
    const todo1 = new TodoItem(1, 'TypeScriptでTDDを学ぶ', false);
    const todo2 = new TodoItem(1, 'TypeScriptでTDDを学ぶ', true);
    expect(todo1.equals(todo2)).toBe(true);
  });

テストを実行して、現在の実装で新しいテストもパスすることを確認します。

npm test

⚛️6. Reactコンポーネントでの使用

作成したTODOアイテムをReactコンポーネントで使用してみましょう。

components/TodoItemComponent.tsx
"use client";

import React from 'react';
import { TodoItem } from '@/lib/todoItem';

interface TodoItemComponentProps {
  todoItem: TodoItem;
  onToggle?: (todoItem: TodoItem) => void;
}

export default function TodoItemComponent({ todoItem, onToggle }: TodoItemComponentProps) {
  const handleToggle = () => {
    if (onToggle) {
      // 3章時点では、新しいインスタンスを手動で作成
      const newTodo = new TodoItem(todoItem.id, todoItem.text, !todoItem.completed);
      onToggle(newTodo);
    }
  };

  return (
    <li className={`flex items-center space-x-2 p-2 ${todoItem.completed ? 'line-through text-gray-500' : ''}`}>
      <input
        type="checkbox"
        checked={todoItem.completed}
        onChange={handleToggle}
        className="form-checkbox"
      />
      <span>#{todoItem.id}: {todoItem.text}</span>
    </li>
  );
}

3章時点での制限

  • 手動でのインスタンス作成toggle()メソッドがないため手動で新しいインスタンスを作成
  • 直接フィールドアクセスtodoItem.completedで直接アクセス
  • 意図が不明確:状態変更の意図が分かりにくい

次の章では、これらの問題を解決する改善されたAPIを実装します。

🚀7. コミットとCIの確認

git add .
git commit -m "feat: Implement TodoItem equality with triangulation (TypeScript)"
git push

GitHub ActionsのCIワークフローが自動的に実行され、テストがパスすることを確認してください。

🎯この章のまとめ

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

  • 三角測量による実装戦略:複数のテストケースを用意してから一般化を行う手法
  • TODOアイテムの等価性実装:IDとテキストによる等価性判定
  • TypeScriptの型安全性活用:インターフェースと型チェックによる堅牢な実装
  • Value Objectパターンの基礎:不変なオブジェクトの設計

次の章では、TODOアイテムのプライベート化と「意図を語るテスト」について学びます。