← トップページに戻る

TDD実践ガイド

🤔第10章 テストに聞いてみる(興味深いtimes)

「システムの知識を総動員して検討することも可能ではあるが、私たちの手元にはきれいなコードがあり、 そのコードが動作しているという自信をテストが与えてくれる。検討に時間を割く代わりに、 コードを変更してテストを実行し、結果をコンピュータに教えてもらえばいい。」

「検討に時間を割く代わりに、コードを変更してテストを実行し、 結果をコンピュータに教えてもらえばいい。」

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

TODOリスト

  • `multiply`メソッドの興味深い実装
  • 型推論を活用したTODOアイテム変換
  • テストから設計を導く手法
  • `TodoItem`型と`TodoList`型の区別

⚠️現在の`multiply`実装の限界

現在のmultiply実装は 単純にTODOアイテムを複製するだけです。 しかし、実際のアプリケーションでは、より興味深い動作が求められるかもしれません。 テストに聞いて、設計を導いてみましょう。

🔴1. レッド:興味深い`multiply`のテストを書く

まず、期待する動作をテストで表現してみます。TodoListという新しい概念が必要かもしれません。

lib/TodoItem.test.ts
describe('興味深いmultiply操作のテスト', () => {
  it('TODOアイテムを乗算してTodoListを返すこと', () => {
    const learningTodo = TodoItem.learning(1, 'TypeScriptを練習する');
    const todoList = learningTodo.multiplyToList(3);
    
    expect(todoList.size()).toBe(3);
    expect(todoList.getCategory()).toBe('LEARNING');
    expect(todoList.getAll().every(todo => 
      todo.text === 'TypeScriptを練習する'
    )).toBe(true);
  });

  it('乗算時に異なるTODOタイプを作成すること', () => {
    const workTodo = TodoItem.work(1, 'コードレビュー');
    
    // Workカテゴリーは違うIDを持つべき
    const multipliedTodos = workTodo.multiply(2);
    expect(multipliedTodos[0].id).not.toBe(multipliedTodos[1].id);
    expect(multipliedTodos[0].equals(multipliedTodos[1])).toBe(false);
  });
});

このテストはTodoListという 新しいクラスを要求しています。テストに聞いてみましょう -TodoListが必要でしょうか?

2. テストに聞く:`TodoList`は`TodoItem`と同じか?

実験的なテストを書いて、システムの動作を確認します。

lib/TodoList.test.ts
describe('TodoListの実験テスト', () => {
  it('TodoListはMoneyのように振る舞うべきか?', () => {
    const workTodo = TodoItem.work(1, 'アプリをデプロイする');
    const todoList = workTodo.multiplyToList(1);
    
    // TodoListは単一のTodoItemと等価であるべきか?
    expect(todoList.equals(workTodo)).toBe(true);
  });

  it('乗算のエッジケースを処理すること', () => {
    const personalTodo = TodoItem.personal(5, '牛乳を買う');
    
    // ゼロでの乗算
    const zeroResult = personalTodo.multiplyToList(0);
    expect(zeroResult.size()).toBe(0);
    
    // 負数での乗算  
    const negativeResult = personalTodo.multiplyToList(-1);
    expect(negativeResult.size()).toBe(0);
  });
});

🟢3. グリーン:最小限の`TodoList`実装

テストを実行すると失敗します。まだTodoListクラスが 存在しないからです。実装してみましょう。

lib/TodoList.ts
export interface ITodoList {
  size(): number;
  getCategory(): string;
  getAll(): ITodoItem[];
  equals(other: ITodoItem | ITodoList): boolean;
}

export class TodoList implements ITodoList {
  constructor(
    private items: ITodoItem[],
    private category: string
  ) {}

  size(): number {
    return this.items.length;
  }

  getCategory(): string {
    return this.category;
  }

  getAll(): ITodoItem[] {
    return [...this.items]; // 防御的コピー
  }

  equals(other: ITodoItem | ITodoList): boolean {
    if (other instanceof TodoList) {
      return this.category === other.category &&
             this.size() === other.size() &&
             this.items.every((item, index) => 
               item.equals(other.items[index])
             );
    }
    
    // TodoItemとの比較: サイズ1でアイテムが等しい場合のみtrue
    if (this.size() === 1) {
      return this.items[0].equals(other);
    }
    
    return false;
  }

  // Factory Method
  static fromItem(item: ITodoItem, count: number = 1): TodoList {
    if (count <= 0) {
      return new TodoList([], item.category);
    }

    const items = Array.from({ length: count }, (_, i) =>
      TodoItem.create(
        item.id + i,
        item.text,
        item.category as any
      )
    );

    return new TodoList(items, item.category);
  }
}

🔵4. リファクタリング:型推論の活用

TypeScriptの型推論を活用して、より安全で使いやすいAPIを提供します。

lib/TodoList.ts (改良版)
export class TodoList implements ITodoList {
  // ... 既存のメソッド

  equals(other: ITodoItem | ITodoList): boolean {
    if (this.isTodoList(other)) {
      return this.compareWithTodoList(other);
    }
    return this.compareWithTodoItem(other);
  }

  private isTodoList(obj: ITodoItem | ITodoList): obj is ITodoList {
    return 'size' in obj && 'getCategory' in obj;
  }

  private compareWithTodoList(other: ITodoList): boolean {
    return this.category === other.getCategory() &&
           this.size() === other.size() &&
           this.items.every((item, index) => 
             item.equals(other.getAll()[index])
           );
  }

  private compareWithTodoItem(other: ITodoItem): boolean {
    return this.size() === 1 && 
           this.items[0].equals(other);
  }
}

🟢5. `TodoItem`に`multiplyToList`メソッドを追加

lib/TodoItem.ts
export interface ITodoItem {
  readonly id: number;
  readonly text: string;
  readonly completed: boolean;
  readonly category: string;
  complete(): ITodoItem;
  multiply(count: number): ITodoItem[];
  multiplyToList(count: number): ITodoList; // 新しいメソッド
  equals(other: ITodoItem): boolean;
}

// 実装クラスに追加
class IncompleteTodoItem extends TodoItem {
  // ... 既存のコード

  multiplyToList(count: number): ITodoList {
    return TodoList.fromItem(this, count);
  }
}

class CompletedTodoItem extends TodoItem {
  // ... 既存のコード

  multiplyToList(count: number): ITodoList {
    // 完了済みアイテムは自分自身のリストを返す
    return TodoList.fromItem(this, 1);
  }
}

🧪6. テストに聞く:異なる型のTODOを区別できるか?

実験的なテストを書いて、システムの動作を確認します。

lib/TodoItem.test.ts
describe('TodoItemタイプの実験テスト', () => {
  it('TodoItemは適切な場合にTodoListを返すこと', () => {
    const workTodo = TodoItem.work(1, 'PRをレビューする');
    const result = workTodo.multiplyToList(1);
    
    // 結果は TodoList 型であるべき
    expect(result).toBeInstanceOf(TodoList);
    
    // しかし、単一のTodoItemと等価であるべき
    expect(result.equals(workTodo)).toBe(true);
  });

  it('複雑な乗算シナリオを処理すること', () => {
    const dailyStandup = TodoItem.work(1, '朝会');
    
    // 一週間分の朝会TODO作成
    const weeklyStandups = dailyStandup.multiplyToList(5);
    
    expect(weeklyStandups.size()).toBe(5);
    expect(weeklyStandups.getCategory()).toBe('WORK');
    
    weeklyStandups.getAll().forEach((todo, index) => {
      expect(todo.text).toBe('朝会');
      expect(todo.id).toBe(1 + index);
    });
  });
});

⚛️7. React コンポーネントでの活用

components/TodoListComponent.tsx
"use client";

import React from 'react';
import { ITodoList } from '@/lib/TodoList';
import { TodoItemComponent } from './TodoItemComponent';

interface TodoListComponentProps {
  todoList: ITodoList;
}

export function TodoListComponent({ todoList }: TodoListComponentProps) {
  return (
    <div className="bg-white rounded-lg shadow p-4">
      <h3 className="text-lg font-semibold mb-3">
        {todoList.getCategory()} ({todoList.size()} items)
      </h3>
      <ul className="space-y-2">
        {todoList.getAll().map((todo, index) => (
          <TodoItemComponent 
            key={`${todo.id}-${index}`}
            todo={todo} 
            onToggle={() => {}} 
          />
        ))}
      </ul>
    </div>
  );
}

💡8. テストから学んだ設計判断

テストに聞いた結果、以下の設計判断を行いました:

`TodoList`の必要性

テストが`TodoList`型を要求し、`TodoItem`とは異なる動作が必要

等価性の定義

`TodoList`は条件によって`TodoItem`と等価になる

型安全性

TypeScriptの型ガードで安全な型判定を実現

Factory Methodの活用

一貫した生成パターンの提供

🚀9. Next.js App Routerでの統合

app/multiply-demo/page.tsx
import { TodoListComponent } from '@/components/TodoListComponent';
import { TodoItem } from '@/lib/TodoItem';

export default function MultiplyDemoPage() {
  const examples = [
    TodoItem.work(1, 'コードレビュー').multiplyToList(3),
    TodoItem.learning(10, 'TypeScript練習').multiplyToList(2),
    TodoItem.personal(20, '運動').multiplyToList(5)
  ];

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Multiply Demo</h1>
      <p className="text-gray-600 mb-8">
        テストに聞いて設計した multiply 機能のデモンストレーション
      </p>
      
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {examples.map((todoList, index) => (
          <TodoListComponent key={index} todoList={todoList} />
        ))}
      </div>
    </main>
  );
}

📚テストに聞く手法の学び

この章で学んだ「テストに聞く」手法:

実験的なテストを書く

期待する動作が正しいかテストで確認

設計の疑問をテストで解決

コードで答えを見つける

型システムの活用

TypeScriptの型推論で安全な設計

小さな変更で大きな気づき

テストが設計の方向性を示す

TODOリストの更新

  • `multiply`メソッドの興味深い実装
  • 型推論を活用したTODOアイテム変換
  • テストから設計を導く手法
  • `TodoItem`型と`TodoList`型の区別

🎯まとめ

「テストに聞いてみる」手法を使って、TodoListという 新しい概念を発見し、より洗練された設計を導き出しました。 テストが設計の方向性を示し、コードで答えを見つけることで、 効果的な設計判断ができるようになります。 次の章では、「不要になったら消す」原則を学び、 重複コードの発見と除去を通じてクリーンな設計を実現します。