← トップページに戻る

TDD実践ガイド

🔒第4章 TODOアイテムのプライベート化(意図を語るテスト、TypeScript版)

前章で等価性が定義できたので、テストがより「雄弁」になりました。この章では、TODOアイテムの内部構造をプライベート化し、テストをより「意図を語る」ものに改善します。

作成したばかりの機能を使って、テストを改善した。 そもそも正しく検証できていないテストが2つあったら、もはやお手上げだと気づいた。 そのようなリスクを受け入れて先に進んだ。 テスト対象オブジェクトの新しい機能を使い、テストコードとプロダクトコードの間の結合度を下げた。
— Kent Beck『テスト駆動開発』第4章「意図を語るテスト」より

TODOリスト

  • TODOアイテムのフィールドをプライベートにする
  • テストを等価性で比較するように改善する
  • 操作の意図をより明確にする

🔍1. 現在の実装の問題点

前章で作成したTODOアイテムは、等価性の実装が完了しています。現在の実装を確認してみましょう。

現在のlib/todoItem.ts(3章終了時点)
export class TodoItem {
  constructor(
    public id: number,        // publicで直接アクセス可能
    public text: string,      // publicで直接アクセス可能
    public completed: boolean = false
  ) {}

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

この実装は等価性の基本機能は動作しますが、さらに改善できる点があります:

  • フィールドのプライベート化:現在はpublicで外部から直接変更可能
  • 意図を明確にするメソッドの追加isCompleted()complete()など
  • テストの改善:内部構造ではなく振る舞いに焦点を当てたテスト
  • 型安全性の向上:nullチェックやinstanceofチェック
現在可能な直接アクセス(改善したい点)
const todo = new TodoItem(1, 'TypeScriptでTDDを学ぶ');

// 直接フィールドにアクセス・変更が可能(プライベート化したい)
console.log(todo.id);        // 1
console.log(todo.text);      // 'TypeScriptでTDDを学ぶ'
console.log(todo.completed); // false

// さらに危険:直接変更もできてしまう
todo.id = 999;               // ❌ IDを変更
todo.text = '別の内容';      // ❌ 内容を変更
todo.completed = true;       // ❌ 状態を変更

// 型安全性の問題もある
todo.equals(null);           // ❌ エラーが発生する可能性

// テストでも直接フィールドを参照(意図を語るテストに改善したい)
expect(todo.id).toBe(1);
expect(todo.text).toBe('TypeScriptでTDDを学ぶ');
expect(todo.completed).toBe(false);

この実装は等価性の基本機能は動作しますが、さらに改善できる点があります:

  • フィールドのプライベート化:現在はpublicで外部から直接変更可能
  • 意図を明確にするメソッドの追加isCompleted()complete()など
  • テストの改善:内部構造ではなく振る舞いに焦点を当てたテスト
  • 型安全性の向上:nullチェックやinstanceofチェック
現在の実装(問題あり)
equals(other: TodoItem): boolean {
  return this.id === other.id && this.text === other.text;
}

// 問題:nullやundefinedが渡されるとエラーになる
const todo = new TodoItem(1, 'テスト');
todo.equals(null); // ❌ TypeError: Cannot read property 'id' of null

🔒2. 「プライベート化」とは?

💡 プライベート化の基本概念

プライベート化とは、クラスの内部データを外部から直接アクセスできないようにすることです。 代わりに、必要な操作だけをメソッドとして公開します。

✅ 外部に公開する機能(public)

使ってもらいたい機能
// これらは外から使ってほしい
todo.isCompleted()  // 完了状態の確認
todo.complete()     // 完了にする
todo.toggle()       // 状態を切り替える
todo.equals(other)  // 等価性の比較

✅ これらのメソッドは意図が明確で、安全に使用できます

🎯 なぜプライベート化が重要なのか

1. 🛡️ 意図しない変更を防ぐ

現在の実装では、外部から直接フィールドを変更できてしまい、オブジェクトが壊れる可能性があります。

現在の危険な例
const todo = new TodoItem(1, '買い物');

// ❌ 現在は直接変更できてしまう(危険)
todo.id = 999;               // IDが勝手に変更される
todo.text = '全然違う内容';   // 内容が勝手に変更される  
todo.completed = true;       // 状態が勝手に変更される

console.log(todo.id);        // 999 - 元のIDが失われた
console.log(todo.text);      // '全然違う内容' - 元の内容が失われた

プライベート化後は、このような直接変更ができなくなり、安全になります。

2. 🎯 使い方を明確にする

どの機能を使ってほしいかが明確になり、コードが使いやすくなります。

プライベート化後の明確な使い方
const todo = new TodoItem(1, '買い物');

// ✅ 意図が明確な操作方法
if (todo.isCompleted()) {     // 完了状態の確認
  console.log('完了済み');
}

const completedTodo = todo.complete();  // 完了にする
const toggledTodo = todo.toggle();      // 状態を切り替える

3. 🔧 将来の変更に強くなる

内部実装を変更しても、外部のコードを壊さずに済みます。

将来の拡張例
// 内部実装を変更しても...
class TodoItem {
  private _id: number;
  private _createdAt: Date;    // 新しく追加
  private _priority: number;   // 新しく追加
  private _tags: string[];     // 新しく追加
  
  // 外から使う部分(public API)は変わらない
  isCompleted(): boolean { /* ... */ }
  complete(): TodoItem { /* ... */ }
  toggle(): TodoItem { /* ... */ }
}

📝 まとめ

プライベート化 = 内部データを外から直接触れないようにすること

目的 = 安全で使いやすく、変更に強いコードを作ること

方法 = 必要な機能だけをメソッドとして公開する

💬3. 「意図を語るテスト」って何?

🤔 現在のテストの問題点

現在のテストは等価性を中心に書かれていますが、もし内部フィールドに直接アクセスするテストがあったとしたら、以下のような問題があります:

問題のあるテストの例
// ❌ 内部の詳細に依存したテスト
it('TODOアイテムが正しく作成されること', () => {
  const todo = new TodoItem(1, '買い物');
  expect(todo.id).toBe(1);           // 内部フィールドに直接アクセス
  expect(todo.text).toBe('買い物');   // 内部フィールドに直接アクセス
  expect(todo.completed).toBe(false); // 内部フィールドに直接アクセス
});

何が問題なの?

  • 内部構造に依存:フィールド名が変わったらテストが壊れる
  • 意図が不明確:「何をテストしたいのか」が分からない
  • 実装の詳細をテスト:「どう動くか」ではなく「どう作られているか」をテスト

✅ 「意図を語るテスト」とは?

「意図を語るテスト」とは、「何をしたいのか」「どんな振る舞いを期待するのか」が明確に分かるテストのことです。

❌ 意図が不明確

何をテストしているか分からない
// これだと何をテストしたいのか不明
expect(todo.completed).toBe(false);

✅ 意図が明確

何をテストしているか明確
// 「未完了かどうか確認したい」が明確
expect(todo.isCompleted()).toBe(false);
// または
expect(todo.isIncomplete()).toBe(true);

🔄 テストの改善例

実際にテストを改善してみましょう:

❌ 改善前:内部フィールドに依存

意図が不明確なテスト
it('TODOアイテムが作成されること', () => {
  const todo = new TodoItem(1, '買い物');
  expect(todo.id).toBe(1);
  expect(todo.text).toBe('買い物');
  expect(todo.completed).toBe(false);
});

✅ 改善後:意図が明確

意図が明確なテスト
it('新しく作成されたTODOは未完了状態であること', () => {
  const todo = new TodoItem(1, '買い物');
  expect(todo.isCompleted()).toBe(false);
  expect(todo.isIncomplete()).toBe(true);
});

it('指定した内容でTODOアイテムが作成されること', () => {
  const todo = new TodoItem(1, '買い物');
  const expected = new TodoItem(1, '買い物', false);
  expect(todo.equals(expected)).toBe(true);
});

改善のポイント

  • テスト名が具体的:「何を確認したいか」が明確
  • メソッドを使用isCompleted()で意図が明確
  • 振る舞いに焦点:「どう動くか」をテスト

次のセクションでは、実際にプライベート化された実装と、それに対応する「意図を語るテスト」を作成していきます。

🛠️4. TODOアイテムの段階的な改善

現在の実装を基に、段階的に改善していきます。まず、フィールドをプライベート化し、意図を明確にするメソッドを追加しましょう。

改善されたlib/todoItem.ts
// 1. インターフェースで構造を明確化
export interface ITodoItem {
  readonly id: number;
  readonly text: string;
  readonly completed: boolean;
}

// 2. クラスでインターフェースを実装
export class TodoItem implements ITodoItem {
  private readonly _id: number;
  private readonly _text: string;
  private readonly _completed: boolean;

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

  // ゲッター(読み取り専用)
  get id(): number {
    return this._id;
  }

  get text(): string {
    return this._text;
  }

  get completed(): boolean {
    return this._completed;
  }

  // 意図を明確にするメソッド
  isCompleted(): boolean {
    return this._completed;
  }

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

  // 等価性確認(IDとテキストが同じか)
  equals(other: TodoItem): boolean {
    // 型チェックで安全性を確保
    if (!(other instanceof TodoItem)) {
      return false;
    }
    // IDとテキストが同じときのみ等価とする
    return this._id === other._id && this._text === other._text;
  }

  // 不変性を保つメソッド(新しいインスタンスを返す)
  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);
  }

  // 文字列表現
  toString(): string {
    const status = this._completed ? '✓' : '○';
    return `${status} #${this._id}: ${this._text}`;
  }

  // ファクトリメソッド
  static create(id: number, text: string): TodoItem {
    return new TodoItem(id, text, false);
  }

  static createCompleted(id: number, text: string): TodoItem {
    return new TodoItem(id, text, true);
  }
}

✅ 実装のポイント

  • 新しいTODO追加機能TodoItem.create()ファクトリメソッドを活用
  • 統一されたインターフェースonUpdate一つで全ての状態変更を処理
  • 意図を明確にするメソッドを活用isCompleted(), isIncomplete()を使用
  • 等価性確認メソッドの活用equals()でアイテムの同一性(IDとテキスト)を判定
  • 不変性の維持toggle(), complete()で新しいインスタンスを返す
  • 型安全性:TypeScriptの型システムを活用
  • ユーザビリティ:Enterキーでの追加、空文字列の入力防止

🧪5. 改善されたテストの実装

📋 元のシンプルなテスト(3章終了時点)

3章終了時点では、基本的な等価性のみをテストしていました:

lib/todoItem.test.ts(3章終了時点)
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);
  });
});

元のテストの問題点

  • 基本的な等価性のみ:IDとテキストの比較だけ
  • 完了状態の考慮なし:完了状態が異なる場合のテストがない
  • 型安全性の未考慮:null/undefinedや異なる型との比較テストがない
  • 意図を語るメソッドのテストなしisCompleted()などのテストがない
  • 不変性のテストなし:状態変更時の不変性確認がない

✅ 改善された包括的なテスト(現在)

プライベート化と意図を明確にするメソッドの追加に合わせて、テストも大幅に改善されました:

lib/todoItem.test.ts(改善後の完全版)
import { TodoItem } from './todoItem';

describe('TodoItemのテスト', () => {
  describe('作成', () => {
    it('指定された内容でTODOアイテムを作成すること', () => {
      const todo = new TodoItem(1, 'TypeScriptでTDDを学ぶ');
      const expected = new TodoItem(1, 'TypeScriptでTDDを学ぶ', false);
      expect(todo.equals(expected)).toBe(true);  // 等価性メソッドを活用
    });

    it('デフォルトで未完了のTODOアイテムを作成すること', () => {
      const todo = new TodoItem(1, 'TypeScriptでTDDを学ぶ');
      expect(todo.isCompleted()).toBe(false);  // 意図を明確にするメソッドを使用
      expect(todo.isIncomplete()).toBe(true);
    });
  });

  describe('完了状態の操作', () => {
    it('TODOアイテムを完了状態にすること', () => {
      const todo = new TodoItem(1, 'TypeScriptでTDDを学ぶ');
      const completedTodo = todo.complete();
      
      expect(completedTodo.isCompleted()).toBe(true);
      expect(todo.isCompleted()).toBe(false); // 不変性の確認
    });

    it('完了状態を切り替えること', () => {
      const todo = new TodoItem(1, 'TypeScriptでTDDを学ぶ');
      const toggledTodo = todo.toggle();
      
      expect(toggledTodo.isCompleted()).toBe(true);
      expect(todo.isCompleted()).toBe(false); // 不変性の確認
    });
  });

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

    it('完了状態が異なっても、IDとテキストが同じなら等価であること', () => {
      const todo1 = new TodoItem(1, 'TypeScriptでTDDを学ぶ', false);
      const todo2 = new TodoItem(1, 'TypeScriptでTDDを学ぶ', true);
      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);
    });
  });

  describe('型安全性', () => {
    it('nullとの比較で例外が発生しないこと', () => {
      const todo = new TodoItem(1, 'テスト');
      expect(() => todo.equals(null as any)).not.toThrow();
      expect(todo.equals(null as any)).toBe(false);
    });

    it('undefinedとの比較で例外が発生しないこと', () => {
      const todo = new TodoItem(1, 'テスト');
      expect(() => todo.equals(undefined as any)).not.toThrow();
      expect(todo.equals(undefined as any)).toBe(false);
    });

    it('異なる型のオブジェクトとの比較で例外が発生しないこと', () => {
      const todo = new TodoItem(1, 'テスト');
      const fakeObject = { id: 1, text: 'テスト' };
      expect(() => todo.equals(fakeObject as any)).not.toThrow();
      expect(todo.equals(fakeObject as any)).toBe(false);
    });
  });

  describe('ファクトリメソッド', () => {
    it('createメソッドで未完了のTODOを作成すること', () => {
      const todo = TodoItem.create(1, 'TypeScriptでTDDを学ぶ');
      expect(todo.isIncomplete()).toBe(true);
    });
  
    it('createCompletedメソッドで完了済みのTODOを作成すること', () => {
      const todo = TodoItem.createCompleted(1, 'TypeScriptでTDDを学ぶ');
      expect(todo.isCompleted()).toBe(true);
    });
  });
});

🔄 主な改善点

1. 📝 テスト構造の改善

  • describeでテストをカテゴリ別に整理
  • 作成完了状態の操作等価性型安全性ファクトリメソッドに分類
  • • テストの意図が明確になり、保守しやすくなった

2. 🎯 意図を語るテストの実装

  • isCompleted()isIncomplete()メソッドを使用
  • • 内部フィールドではなく、振る舞いをテスト
  • • テスト名が具体的で、何を確認したいかが明確

3. 🔒 等価性テストの改善

  • 完了状態が異なってもIDとテキストが同じであれば等価であることを確認
  • equals()メソッドがアイテムの同一性(IDとテキスト)を確認することをテスト
  • • より実用的な等価性の定義

4. 🛡️ 型安全性テストの追加

  • nullundefined異なる型との比較テスト
  • • 例外が発生しないことを確認
  • • 本番環境での予期しないエラーを防止

5. 🏭 ファクトリメソッドテストの追加

  • TodoItem.create()TodoItem.createCompleted()のテスト
  • • オブジェクト作成の意図を明確にするメソッドの動作確認
  • • より表現力豊かなAPI

6. 🔄 不変性テストの追加

  • テスト数:3個
  • カバー範囲:基本的な等価性のみ
  • テストカテゴリ:1個(等価性)

📊 テスト数の比較

元のテスト(3章終了時点)

  • テスト数:3個
  • カバー範囲:基本的な等価性のみ
  • テストカテゴリ:1個(等価性)

改善後のテスト(現在)

  • テスト数:13個
  • カバー範囲:全機能を包括的にテスト
  • テストカテゴリ:5個(作成、操作、等価性、型安全性、ファクトリ)

✅ 実装のポイント

  • 新しいTODO追加機能TodoItem.create()ファクトリメソッドを活用
  • 統一されたインターフェースonUpdate一つで全ての状態変更を処理
  • 意図を明確にするメソッドを活用isCompleted(), isIncomplete()を使用
  • 等価性確認メソッドの活用equals()で更新対象を特定
  • 不変性の維持toggle(), complete()で新しいインスタンスを返す
  • 型安全性:TypeScriptの型システムを活用
  • ユーザビリティ:Enterキーでの追加、空文字列の入力防止

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

改良されたTODOアイテムクラスを活用して、Reactコンポーネントを実装します。 ベストプラクティスに基づいた統一されたインターフェースで設計しましょう。

TodoItemComponentの実装

改良されたTodoItemクラスを活用したコンポーネントを作成します:

components/todo/TodoItemComponent.tsx
"use client";

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

interface TodoItemComponentProps {
  todoItem: TodoItem;
  onUpdate: (updatedTodo: TodoItem) => void;
}

export default function TodoItemComponent({ 
  todoItem, 
  onUpdate
}: TodoItemComponentProps) {
  const handleToggle = () => {
    onUpdate(todoItem.toggle());
  };

  const handleComplete = () => {
    if (todoItem.isIncomplete()) {
      onUpdate(todoItem.complete());
    }
  };

  const handleMarkIncomplete = () => {
    if (todoItem.isCompleted()) {
      onUpdate(todoItem.markIncomplete());
    }
  };

  return (
    <li className={`flex items-center justify-between space-x-2 p-3 border rounded-lg ${
      todoItem.isCompleted()
        ? 'bg-green-50 border-green-200 text-green-800' 
        : 'bg-white border-gray-200'
    }`}>
      <div className="flex items-center space-x-3">
        <input
          type="checkbox"
          checked={todoItem.isCompleted()}
          onChange={handleToggle}
          className="form-checkbox h-5 w-5 text-green-600"
        />
        <span className={`${todoItem.isCompleted() ? 'line-through' : ''}`}>
          {todoItem.toString()}
        </span>
      </div>
      
      <div className="flex space-x-2">
        {todoItem.isIncomplete() && (
          <button
            onClick={handleComplete}
            className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"
          >
            完了
          </button>
        )}
        {todoItem.isCompleted() && (
          <button
            onClick={handleMarkIncomplete}
            className="px-3 py-1 text-sm bg-gray-500 text-white rounded hover:bg-gray-600"
          >
            未完了に戻す
          </button>
        )}
      </div>
    </li>
  );
}

TodoListコンポーネントの実装

等価性メソッドを活用した親コンポーネントを実装します:

components/todo/TodoList.tsx
"use client";

import React, { useState } from 'react';
import TodoItemComponent from '@/components/todo/TodoItemComponent';
import { TodoItem, ITodoItem } from '@/lib/todoItem';

interface TodoListProps {
  initialTodos: ITodoItem[];
}

export default function TodoList({ initialTodos }: TodoListProps) {
  const [todos, setTodos] = useState<TodoItem[]>(
    initialTodos.map(todo => new TodoItem(todo.id, todo.text, todo.completed))
  );
  const [newTodoText, setNewTodoText] = useState('');

  const handleTodoUpdate = (updatedTodo: TodoItem) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => 
        todo.equals(updatedTodo) ? updatedTodo : todo
      )
    );
  };

  const handleAddTodo = () => {
    if (newTodoText.trim()) {
      const newId = Math.max(0, ...todos.map(todo => todo.id)) + 1;
      const newTodo = TodoItem.create(newId, newTodoText.trim());
      setTodos(prevTodos => [...prevTodos, newTodo]);
      setNewTodoText('');
    }
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleAddTodo();
    }
  };

  return (
    <div className="w-full max-w-2xl mx-auto">
      {/* 新しいTODO追加フォーム */}
      <div className="mb-6 flex gap-2">
        <input
          type="text"
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="新しいTODOを入力..."
          className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        <button
          onClick={handleAddTodo}
          disabled={!newTodoText.trim()}
          className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
        >
          追加
        </button>
      </div>

      {/* TODOリスト */}
      {todos.length > 0 ? (
        <ul className="w-full space-y-2">
          {todos.map((todo) => (
            <TodoItemComponent 
              key={todo.id} 
              todoItem={todo} 
              onUpdate={handleTodoUpdate}
            />
          ))}
        </ul>
      ) : (
        <p className="text-gray-500 text-center py-8">No todos yet!</p>
      )}
    </div>
  );
}

ページでの使用例

実際のページでの使用例です:

app/todo/page.tsx
import TodoList from '@/components/todo/TodoList';
import { ITodoItem, TodoItem } from '@/lib/todoItem';

export default function HomePage() {
  const initialTodos: ITodoItem[] = [
    { id: 1, text: 'TypeScriptでTDDを学ぶ', completed: false },
    { id: 2, text: 'Reactコンポーネントを作成する', completed: false },
    { id: 3, text: 'プライベート化を理解する', completed: true },
  ];

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex flex-col">
        <h1 className="text-4xl font-bold mb-8">My TODO List</h1>
        <TodoList initialTodos={initialTodos} />
      </div>
    </main>
  );
}

✅ 実装のポイント

  • 新しいTODO追加機能TodoItem.create()ファクトリメソッドを活用
  • 統一されたインターフェースonUpdate一つで全ての状態変更を処理
  • 意図を明確にするメソッドを活用isCompleted(), isIncomplete()を使用
  • 等価性確認メソッドの活用equals()で更新対象を特定
  • 不変性の維持toggle(), complete()で新しいインスタンスを返す
  • 型安全性:TypeScriptの型システムを活用
  • ユーザビリティ:Enterキーでの追加、空文字列の入力防止

🧪7. Reactコンポーネントのテスト

改良されたTODOアイテムクラスを使ったテスト `TodoItemComponent.test.tsx` も実装しましょう。

コンポーネントテスト

components/todo/TodoItemComponent.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoItemComponent from './TodoItemComponent';
import { TodoItem } from '@/lib/todoItem';

describe('TodoItemComponentのテスト', () => {
  it('未完了のTODOアイテムを表示すること', () => {
    const todo = TodoItem.create(1, 'TypeScriptでTDDを学ぶ');
    const onUpdate = jest.fn();
    render(<TodoItemComponent todoItem={todo} onUpdate={onUpdate} />);
    
    expect(screen.getByText(/TypeScriptでTDDを学ぶ/)).toBeInTheDocument();
    expect(screen.getByRole('checkbox')).not.toBeChecked();
    expect(screen.getByText('完了')).toBeInTheDocument();
  });

  it('完了済みのTODOアイテムを表示すること', () => {
    const todo = TodoItem.createCompleted(1, 'TypeScriptでTDDを学ぶ');
    const onUpdate = jest.fn();
    render(<TodoItemComponent todoItem={todo} onUpdate={onUpdate} />);
    
    expect(screen.getByRole('checkbox')).toBeChecked();
    expect(screen.getByText('未完了に戻す')).toBeInTheDocument();
  });

  it('チェックボックスクリックでonUpdateが正しい値で呼ばれること', () => {
    const todo = TodoItem.create(1, 'TypeScriptでTDDを学ぶ');
    const onUpdate = jest.fn();
    render(<TodoItemComponent todoItem={todo} onUpdate={onUpdate} />);
    
    fireEvent.click(screen.getByRole('checkbox'));
    
    // onUpdateが呼ばれ、引数が正しいことを確認
    expect(onUpdate).toHaveBeenCalledTimes(1);
    const calledWith = onUpdate.mock.calls[0][0];
    expect(calledWith.isCompleted()).toBe(true);
    expect(calledWith.equals(todo)).toBe(true);
  });

  it('完了ボタンクリックでonUpdateが呼ばれること', () => {
    const todo = TodoItem.create(1, 'TypeScriptでTDDを学ぶ');
    const onUpdate = jest.fn();
    render(<TodoItemComponent todoItem={todo} onUpdate={onUpdate} />);
    
    fireEvent.click(screen.getByText('完了'));
    
    expect(onUpdate).toHaveBeenCalledTimes(1);
    const calledWith = onUpdate.mock.calls[0][0];
    expect(calledWith.isCompleted()).toBe(true);
  });

  it('未完了に戻すボタンクリックでonUpdateが呼ばれること', () => {
    const todo = TodoItem.createCompleted(1, 'TypeScriptでTDDを学ぶ');
    const onUpdate = jest.fn();
    render(<TodoItemComponent todoItem={todo} onUpdate={onUpdate} />);
    
    fireEvent.click(screen.getByText('未完了に戻す'));
    
    expect(onUpdate).toHaveBeenCalledTimes(1);
    const calledWith = onUpdate.mock.calls[0][0];
    expect(calledWith.isCompleted()).toBe(false);
  });

  it('意図を明確にするメソッドが正しく動作すること', () => {
    const incompleteTodo = TodoItem.create(1, 'テスト');
    const completedTodo = TodoItem.createCompleted(2, 'テスト2');
    
    // 未完了のTODO
    expect(incompleteTodo.isCompleted()).toBe(false);
    expect(incompleteTodo.isIncomplete()).toBe(true);
    
    // 完了済みのTODO
    expect(completedTodo.isCompleted()).toBe(true);
    expect(completedTodo.isIncomplete()).toBe(false);
  });
});

統合テストの提案

TodoListコンポーネントの統合テストも作成できます:

components/todo/TodoList.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoList from './TodoList';
import { ITodoItem } from '@/lib/todoItem';

describe('TodoListの統合テスト', () => {
  const initialTodos: ITodoItem[] = [
    { id: 1, text: 'TypeScriptでTDDを学ぶ', completed: false },
    { id: 2, text: 'Reactコンポーネントを作成する', completed: false },
    { id: 3, text: 'プライベート化を理解する', completed: true },
  ];

  it('TODOリストが正しく表示されること', () => {
    render(<TodoList initialTodos={initialTodos} />);
    
    expect(screen.getByText(/TypeScriptでTDDを学ぶ/)).toBeInTheDocument();
    expect(screen.getByText(/Reactコンポーネントを作成する/)).toBeInTheDocument();
    expect(screen.getByText(/プライベート化を理解する/)).toBeInTheDocument();
  });

  it('チェックボックスクリックで実際に状態が変更されること', () => {
    render(<TodoList initialTodos={initialTodos} />);
    
    // 未完了のTODOのチェックボックスをクリック
    const checkboxes = screen.getAllByRole('checkbox');
    const incompleteCheckbox = checkboxes.find(cb => !cb.checked);
    fireEvent.click(incompleteCheckbox!);
    
    // 状態が更新されることを確認(チェックボックスがチェックされる)
    expect(incompleteCheckbox).toBeChecked();
  });

  it('空のリストの場合は適切なメッセージを表示すること', () => {
    render(<TodoList initialTodos={[]} />);
    
    expect(screen.getByText('No todos yet!')).toBeInTheDocument();
  });
});

テストのポイント

  • 統一されたインターフェースonUpdate一つで全ての操作をテスト
  • ファクトリメソッドの活用TodoItem.create(), TodoItem.createCompleted()を使用
  • 意図を明確にするメソッドのテストisCompleted(), isIncomplete()を検証
  • 不変性の確認equals()で異なるインスタンスであることを確認
  • ユーザビリティ:チェックボックスやボタンクリックの動作をテスト

🏃8. テストの実行

すべてのテストを実行して、改良された実装が正しく動作することを確認しましょう。

npm test

すべてのテストがパスすることを確認してください。

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

git add .
git commit -m "feat: Privatize TodoItem fields and improve intention-revealing tests"
git push

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

🎯この章のまとめ

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

  • 意図を語るテスト:等価性を使った内部構造に依存しないテスト
  • プライベート化:フィールドをプライベートにしてカプセル化を強化
  • ファクトリメソッド:オブジェクト作成の意図を明確にする
  • 不変オブジェクト:状態変更時に新しいインスタンスを返す設計
  • 型安全性の向上:TypeScriptの型システムを活用したAPI設計

次の章では、TODOリストの管理機能を実装し、複数のTODOアイテムを扱う方法を学びます。