← トップページに戻る

TDD実践ガイド

🎭第12章 設計とメタファー(TODOリストの追加)

この章では、TODOアプリの最終章として、「Expression(式)」のメタファーを使って より洗練されたドメインモデルを構築します。数学的な式のように操作を組み合わせ可能な設計を実現します。

「オブジェクトたちに頼ろう。あるオブジェクトが望むように振る舞えないのならば、 同じ外部プロトコル(メソッド群)を備える新たなオブジェクト(Imposterと呼ばれる)を実装し、 仕事をさせればよい。」

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

TODOリスト

  • 式のメタファーをTODOアプリに適応
  • TODOコレクションの抽象化
  • `TaskManager`による操作の統一
  • ドメインモデルの完成

🧮Expression メタファーの適応

Kent Beckの原著では「($2 + 3 CHF) * 5」のような 式を表現しました。TODOアプリでは、これを「TODO操作の組み合わせ」として解釈します。

例:TODO操作の式

(work_tasks + personal_tasks).filter(incomplete).priority(high)

🔴1. レッド:TODO Expression のテストを書く

lib/TodoExpression.test.ts
import { TodoExpression } from './TodoExpression';
import { TaskManager } from './TaskManager';
import { TodoItem } from './TodoItem';

describe('TodoExpressionのテスト', () => {
  it('数学的な式のようにTODOコレクションを組み合わせること', () => {
    const workTodos = TodoExpression.fromItems([
      TodoItem.work(1, 'アプリをデプロイする'),
      TodoItem.work(2, 'コードレビュー')
    ]);

    const personalTodos = TodoExpression.fromItems([
      TodoItem.personal(3, '食材を買う'),
      TodoItem.personal(4, '運動する')
    ]);

    const combined = workTodos.plus(personalTodos);
    const taskManager = new TaskManager();
    const result = taskManager.evaluate(combined);

    expect(result.length).toBe(4);
    expect(result.some(todo => todo.category === 'WORK')).toBe(true);
    expect(result.some(todo => todo.category === 'PERSONAL')).toBe(true);
  });
});

🟢2. グリーン:Expression インターフェースの実装

lib/TodoExpression.ts
export interface ITodoExpression {
  plus(other: ITodoExpression): ITodoExpression;
  filter(predicate: (todo: ITodoItem) => boolean): ITodoExpression;
  evaluate(manager: ITaskManager): ITodoItem[];
}

export interface ITaskManager {
  evaluate(expression: ITodoExpression): ITodoItem[];
}

export class TodoExpression implements ITodoExpression {
  constructor(private items: ITodoItem[]) {}

  plus(other: ITodoExpression): ITodoExpression {
    return new TodoSum(this, other);
  }

  filter(predicate: (todo: ITodoItem) => boolean): ITodoExpression {
    return new TodoFilter(this, predicate);
  }

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

  static fromItems(items: ITodoItem[]): ITodoExpression {
    return new TodoExpression(items);
  }
}

// Composite パターンの実装
class TodoSum implements ITodoExpression {
  constructor(
    private left: ITodoExpression,
    private right: ITodoExpression
  ) {}

  plus(other: ITodoExpression): ITodoExpression {
    return new TodoSum(this, other);
  }

  filter(predicate: (todo: ITodoItem) => boolean): ITodoExpression {
    return new TodoFilter(this, predicate);
  }

  evaluate(manager: ITaskManager): ITodoItem[] {
    const leftResult = this.left.evaluate(manager);
    const rightResult = this.right.evaluate(manager);
    return [...leftResult, ...rightResult];
  }
}

🟢3. TaskManager の実装

lib/TaskManager.ts
export class TaskManager implements ITaskManager {
  private priorities: Map<string, number> = new Map();

  evaluate(expression: ITodoExpression): ITodoItem[] {
    return expression.evaluate(this);
  }

  setPriority(category: string, priority: number): void {
    this.priorities.set(category, priority);
  }

  getPriority(category: string): number {
    return this.priorities.get(category) || 0;
  }

  // ヘルパーメソッド
  sortByPriority(todos: ITodoItem[]): ITodoItem[] {
    return [...todos].sort((a, b) => {
      const aPriority = this.getPriority(a.category);
      const bPriority = this.getPriority(b.category);
      return bPriority - aPriority; // 高優先度が先
    });
  }

  // 共通フィルター
  static incomplete = (todo: ITodoItem) => !todo.completed;
  static completed = (todo: ITodoItem) => todo.completed;
  static byCategory = (category: string) => (todo: ITodoItem) => 
    todo.category === category;
}

🔵4. リファクタリング:チェーン可能なAPIの設計

lib/TodoExpression.ts
export class TodoExpression implements ITodoExpression {
  constructor(private items: ITodoItem[]) {}

  plus(other: ITodoExpression): ITodoExpression {
    return new TodoSum(this, other);
  }

  filter(predicate: (todo: ITodoItem) => boolean): ITodoExpression {
    return new TodoFilter(this, predicate);
  }

  // チェーン可能なAPI
  incomplete(): ITodoExpression {
    return this.filter(TaskManager.incomplete);
  }

  completed(): ITodoExpression {
    return this.filter(TaskManager.completed);
  }

  category(cat: string): ITodoExpression {
    return this.filter(TaskManager.byCategory(cat));
  }

  // 数学的操作のメタファー
  multiply(count: number): ITodoExpression {
    const multipliedItems: ITodoItem[] = [];
    this.items.forEach(item => {
      multipliedItems.push(...item.multiply(count));
    });
    return new TodoExpression(multipliedItems);
  }

  // Factory Methods
  static work(...items: ITodoItem[]): ITodoExpression {
    return new TodoExpression(items);
  }

  static personal(...items: ITodoItem[]): ITodoExpression {
    return new TodoExpression(items);
  }

  static fromItems(items: ITodoItem[]): ITodoExpression {
    return new TodoExpression(items);
  }
}

🧪5. 実際の使用例テスト

lib/TodoExpression.test.ts
describe('TodoExpression 高度な使用例のテスト', () => {
  it('数学的な式のような複雑な表現を処理できること', () => {
    const workTodos = TodoExpression.work(
      TodoItem.work(1, 'デプロイ'),
      TodoItem.work(2, 'レビュー', true) // 完了済み
    );

    const personalTodos = TodoExpression.personal(
      TodoItem.personal(3, '運動'),
      TodoItem.personal(4, '買い物', true) // 完了済み
    );

    // 式: (work + personal).incomplete()
    const expression = workTodos
      .plus(personalTodos)
      .incomplete();

    const taskManager = new TaskManager();
    const result = taskManager.evaluate(expression);

    expect(result).toHaveLength(2);
    expect(result.every(todo => !todo.completed)).toBe(true);
  });

  it('数学的な操作のように式を乗算できること', () => {
    const dailyTasks = TodoExpression.fromItems([
      TodoItem.work(1, '朝会')
    ]);

    // 式: dailyTasks * 5 (一週間分)
    const weeklyTasks = dailyTasks.multiply(5);
    
    const taskManager = new TaskManager();
    const result = taskManager.evaluate(weeklyTasks);

    expect(result).toHaveLength(5);
    expect(result.every(todo => todo.text === '朝会')).toBe(true);
  });
});

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

components/ExpressionBuilder.tsx
"use client";

import React, { useState, useMemo } from 'react';
import { TodoExpression } from '@/lib/TodoExpression';
import { TaskManager } from '@/lib/TaskManager';
import { TodoItem } from '@/lib/TodoItem';

export function ExpressionBuilder() {
  const [includeWork, setIncludeWork] = useState(true);
  const [includePersonal, setIncludePersonal] = useState(true);
  const [onlyIncomplete, setOnlyIncomplete] = useState(false);

  const result = useMemo(() => {
    let expression = TodoExpression.fromItems([]);

    if (includeWork) {
      const workTodos = TodoExpression.fromItems([
        TodoItem.work(1, '本番環境にデプロイ'),
        TodoItem.work(2, 'コードレビュー', true)
      ]);
      expression = expression.plus(workTodos);
    }

    if (includePersonal) {
      const personalTodos = TodoExpression.fromItems([
        TodoItem.personal(3, '朝のジョギング'),
        TodoItem.personal(4, '食材の買い物', true)
      ]);
      expression = expression.plus(personalTodos);
    }

    if (onlyIncomplete) {
      expression = expression.incomplete();
    }

    const taskManager = new TaskManager();
    taskManager.setPriority('WORK', 3);
    taskManager.setPriority('PERSONAL', 1);

    const evaluated = taskManager.evaluate(expression);
    return taskManager.sortByPriority(evaluated);
  }, [includeWork, includePersonal, onlyIncomplete]);

  return (
    <div className="space-y-6">
      <div className="bg-gray-50 p-4 rounded-lg">
        <h3 className="text-lg font-semibold mb-4">Expression Builder</h3>
        
        <div className="text-sm text-gray-600 mb-4">
          Expression: {`(${[
            includeWork && 'work',
            includePersonal && 'personal'
          ].filter(Boolean).join(' + ')})${onlyIncomplete ? '.incomplete()' : ''}`}
        </div>

        <div className="grid grid-cols-3 gap-4 mb-4">
          <label className="flex items-center space-x-2">
            <input
              type="checkbox"
              checked={includeWork}
              onChange={(e) => setIncludeWork(e.target.checked)}
            />
            <span>Work Tasks</span>
          </label>
          
          <label className="flex items-center space-x-2">
            <input
              type="checkbox"
              checked={includePersonal}
              onChange={(e) => setIncludePersonal(e.target.checked)}
            />
            <span>Personal Tasks</span>
          </label>
          
          <label className="flex items-center space-x-2">
            <input
              type="checkbox"
              checked={onlyIncomplete}
              onChange={(e) => setOnlyIncomplete(e.target.checked)}
            />
            <span>Incomplete Only</span>
          </label>
        </div>
      </div>

      <div>
        <h4 className="font-medium mb-3">Results ({result.length} items):</h4>
        <ul className="space-y-2">
          {result.map((todo) => (
            <li key={todo.id} className="p-2 border rounded">
              <span className={`${todo.completed ? 'line-through text-gray-500' : ''}`}>
                {todo.text}
              </span>
              <span className="ml-2 text-xs bg-gray-200 px-2 py-1 rounded">
                {todo.category}
              </span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

🚀7. Next.js App Router での統合例

app/expression-demo/page.tsx
import { ExpressionBuilder } from '@/components/ExpressionBuilder';

export default function ExpressionDemoPage() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">TODO Expression Demo</h1>
      <p className="text-gray-600 mb-8">
        数学的な式のメタファーを使ったTODO操作のデモンストレーション
      </p>
      
      <ExpressionBuilder />
      
      <div className="mt-12 bg-blue-50 p-6 rounded-lg">
        <h2 className="text-xl font-semibold mb-4">Design Patterns Used</h2>
        <ul className="space-y-2 text-sm">
          <li><strong>Expression Pattern:</strong> 複雑な操作を組み合わせ可能な式として表現</li>
          <li><strong>Composite Pattern:</strong> TodoSum, TodoFilterで階層構造を構築</li>
          <li><strong>Strategy Pattern:</strong> TaskManagerで評価戦略を分離</li>
          <li><strong>Builder Pattern:</strong> チェーン可能なAPIで式を構築</li>
        </ul>
      </div>
    </main>
  );
}

🧪8. 統合テスト

lib/integration.test.ts
describe('TODO Expression 統合テスト', () => {
  it('実際のシナリオを処理できること', () => {
    // 実際のプロジェクト管理シナリオ
    const sprintBacklog = TodoExpression.fromItems([
      TodoItem.work(1, 'ユーザー認証の実装'),
      TodoItem.work(2, 'データベーススキーマの設計'),
      TodoItem.work(3, 'API ドキュメントの作成', true),
    ]);

    const personalGoals = TodoExpression.fromItems([
      TodoItem.learning(10, 'TypeScript をマスターする'),
      TodoItem.personal(20, '運動習慣'),
    ]);

    // 複雑な式: (sprint + goals).incomplete().priority_sorted
    const expression = sprintBacklog
      .plus(personalGoals)
      .incomplete();

    const taskManager = new TaskManager();
    taskManager.setPriority('WORK', 10);
    taskManager.setPriority('LEARNING', 5);
    taskManager.setPriority('PERSONAL', 1);

    const result = taskManager.evaluate(expression);
    const sorted = taskManager.sortByPriority(result);

    expect(sorted).toHaveLength(4); // 完了済みは除外
    expect(sorted[0].category).toBe('WORK'); // 最高優先度
    expect(sorted[sorted.length - 1].category).toBe('PERSONAL'); // 最低優先度
  });
});

🏆設計の成果

Expression メタファーの導入により、以下を実現しました:

複雑な操作の組み合わせ

数学的な式のように操作を組み合わせ可能

型安全な設計

TypeScriptの型システムで安全性を保証

拡張可能な設計

新しい操作を簡単に追加可能

直感的なAPI

自然言語のような記述が可能

🏗️ドメインモデルの完成

この章で、TODOアプリのドメインモデルが完成しました:

TodoItem

個々のタスクを表現

TodoExpression

タスクの集合と操作を表現

TaskManager

評価とビジネスルールを管理

React Components

UIとドメインロジックを分離

TODOリストの更新

  • 式のメタファーをTODOアプリに適応
  • TODOコレクションの抽象化
  • `TaskManager`による操作の統一
  • ドメインモデルの完成

🎓第2編:設計編 完了

Kent Beckの「テスト駆動開発」第8章〜第12章の設計手法を、TODOアプリ開発に成功的に適応できました。 Factory Method、歩幅調整、テスト駆動設計、重複除去、そしてメタファーを活用した設計まで、 TDDの真髄を実践できました。

次の段階では、これらの設計パターンを実際のNext.js 15アプリケーションでさらに発展させ、 より大規模で実用的なTODOアプリケーションを構築していきます。

🎯まとめ

Expression メタファーを使って、数学的な式のように組み合わせ可能なTODO操作システムを構築しました。 Composite パターン、Strategy パターン、Builder パターンを組み合わせることで、 拡張可能で直感的なドメインモデルが完成しました。 これでTDD設計編の学習は完了です。実際のアプリケーション開発にこれらの手法を活用していきましょう。