📋第5章 複数のTODOタイプ(原則をあえて破るとき、TypeScript版)
この章では、一般的なTODOアイテムに加えて、優先度や期限を持つ「タスクアイテム」を導入します。Kent Beck氏の「原則をあえて破る」教えを実践し、意図的に重複コードを作成してから、後でリファクタリングを行います。
大きいテストに立ち向かうにはまだ早かったので、次の一歩を進めるために小さなテストをひねり出した。 恥知らずにも既存のテストをコピー&ペーストして、テストを作成した。 さらに恥知らずにも、既存のモデルコードを丸ごとコピー&ペーストして、テストを通した。 この重複を排除するまでは家に帰らないと心に決めた。
TODOリスト
- •
TaskItem(優先度付きTODO)の掛け算ができる
- •
TaskItemの等価性判定ができる
- •
TodoItemとTaskItemの重複を解決する
📋1. 新しい要件:TaskItem
通常のTODOアイテムに加えて、優先度と期限を持つ「タスクアイテム」が必要になったとします。まずは既存のTODOアイテムのテストをコピーして、TaskItemのテストを作成しましょう。
🔴2. レッド:TaskItemのテストを書く(コピー&ペースト)
import { TaskItem } from './taskItem'; // taskItem.ts はまだ存在しない
describe('TaskItemのテスト', () => {
describe('作成', () => {
it('優先度付きのタスクアイテムを作成すること', () => {
const task = TaskItem.create(1, 'プロジェクトマイルストーンを完了する', 'high');
expect(task.isCompleted()).toBe(false);
expect(task.getPriority()).toBe('high');
});
it('デフォルトで中優先度のタスクを作成すること', () => {
const task = TaskItem.create(1, 'コードレビューを行う');
expect(task.getPriority()).toBe('medium');
});
});
describe('完了状態の切り替え', () => {
it('完了状態を切り替えること', () => {
const task = TaskItem.create(1, 'プロジェクトマイルストーンを完了する', 'high');
const completedTask = task.toggle();
expect(completedTask.isCompleted()).toBe(true);
expect(task.isCompleted()).toBe(false); // 元のオブジェクトは不変
});
});
describe('優先度管理', () => {
it('優先度レベルを変更すること', () => {
const task = TaskItem.create(1, 'プロジェクトマイルストーンを完了する', 'medium');
const highPriorityTask = task.withPriority('high');
expect(highPriorityTask.getPriority()).toBe('high');
expect(task.getPriority()).toBe('medium'); // 元のオブジェクトは不変
});
});
describe('等価性', () => {
it('IDとテキストが同じ場合は等価であること', () => {
const task1 = TaskItem.create(1, 'プロジェクトマイルストーンを完了する', 'high');
const task2 = TaskItem.create(1, 'プロジェクトマイルストーンを完了する', 'low');
expect(task1.equals(task2)).toBe(true); // 優先度は等価性に影響しない
});
it('IDが異なる場合は等価でないこと', () => {
const task1 = TaskItem.create(1, 'プロジェクトマイルストーンを完了する');
const task2 = TaskItem.create(2, 'プロジェクトマイルストーンを完了する');
expect(task1.equals(task2)).toBe(false);
});
});
});
🟢3. グリーン:TaskItemの実装(意図的な重複)
意図的にTODOアイテムのコードをコピー&ペーストして、TaskItemを作成します。
export type Priority = 'low' | 'medium' | 'high';
export type TaskId = number;
export type TaskText = string;
export interface CreateTaskParams {
readonly id: TaskId;
readonly text: TaskText;
readonly priority?: Priority;
readonly completed?: boolean;
}
export class TaskItem {
private readonly _id: TaskId;
private readonly _text: TaskText;
private readonly _priority: Priority;
private readonly _completed: boolean;
constructor(params: CreateTaskParams);
constructor(id: TaskId, text: TaskText, priority?: Priority, completed?: boolean);
constructor(
paramsOrId: CreateTaskParams | TaskId,
text?: TaskText,
priority: Priority = 'medium',
completed: boolean = false
) {
if (typeof paramsOrId === 'object') {
this._id = paramsOrId.id;
this._text = paramsOrId.text;
this._priority = paramsOrId.priority ?? 'medium';
this._completed = paramsOrId.completed ?? false;
} else {
this._id = paramsOrId;
this._text = text!;
this._priority = priority;
this._completed = completed;
}
}
// ファクトリメソッド
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);
}
// ゲッター
get id(): TaskId { return this._id; }
get text(): TaskText { return this._text; }
get completed(): boolean { return this._completed; }
// TaskItem特有のメソッド
getPriority(): Priority { return this._priority; }
isHighPriority(): boolean { return this._priority === 'high'; }
isMediumPriority(): boolean { return this._priority === 'medium'; }
isLowPriority(): boolean { return this._priority === 'low'; }
// 状態変更メソッド(新しいインスタンスを返す)
isCompleted(): boolean { return this._completed; }
isIncomplete(): boolean { return !this._completed; }
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(other: TaskItem): boolean {
if (!(other instanceof TaskItem)) {
return false;
}
return this._id === other._id && this._text === other._text;
}
toString(): string {
const status = this._completed ? '✓' : '○';
const prioritySymbol = {
high: '🔴',
medium: '🟡',
low: '🟢'
}[this._priority];
return `${status} ${prioritySymbol} #${this._id}: ${this._text}`;
}
}
🚨4. 問題の認識:大量の重複コード
意図的に重複を作ったため、現在のコードベースには深刻な問題が発生しています。 Kent Beck氏の教えに従って「恥知らずにも」コピー&ペーストを行いましたが、 この重複を放置すると開発効率と品質に大きな悪影響を与えます。
具体的な問題点
📋 保守性の悪化
`TodoItem` と `TaskItem` の大部分が同じコードです。バグ修正や機能改善を2箇所で行う必要があり、 一方で修正を忘れるリスクがあります。
⚖️ 一貫性の問題
コピー&ペーストしたコードは時間が経つと微妙に異なる実装になりがちで、 ユーザーインターフェースの一貫性が失われます。
🔧 作業量の増加
新機能追加時に両方のクラスに同じ変更を加える必要があり、開発時間とテストが2倍必要になります。
しかし、これらの問題は意図的に作り出したものです。 次のセクションでリファクタリングを行い、継承を使ってこれらの問題を解決していきます。
💡 なぜ意図的に重複を作ったのか?
最初から完璧な設計を目指すのではなく、まず動くものを作ってから改善する。 実際の要件が明確になってから適切な抽象化を行うためです。
🔵5. リファクタリング:共通基底クラスの抽出
重複を除去するため、共通の基底クラスを作成します。
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 equals(other: BaseItem): boolean;
abstract toString(): string;
// 共通の等価性判定ロジック
protected baseEquals(other: BaseItem): boolean {
return this._id === other._id && this._text === other._text;
}
}
🔄第4章からの継承への移行
📋 第4章終了時点のTodoItem
第4章では、TODOアイテムのプライベート化と意図を語るテストを実装しました。 現在の `TodoItem` クラスには以下の豊富な機能があります:
現在のTodoItemの機能一覧
- • 基本プロパティ:id, text, completed(プライベート化済み)
- • 状態確認メソッド:isCompleted(), isIncomplete()
- • 状態変更メソッド:toggle(), complete(), markIncomplete()
- • 等価性メソッド:equals() - IDとテキストで比較、型安全性対応
- • ファクトリメソッド:create(), createCompleted()
- • 表示メソッド:toString()
- • インターフェース:ITodoItem で構造を明確化
🎯 継承による実装の流れ
この章では、第4章で完成した `TodoItem` の機能を `BaseItem` として抽出し、 `TodoItem` と `TaskItem` の両方がそれを継承する構造にリファクタリングします。
📝6. TodoItemの継承による実装
第4章で完成した `TodoItem` の機能を `BaseItem` として抽出し、 新しい `TodoItem` は `BaseItem` を継承してシンプルになります。
import { BaseItem, ItemId, ItemText } from './baseItem';
// 第4章の ITodoItem インターフェースを継承関係に対応
export interface ITodoItem {
readonly id: number;
readonly text: string;
readonly completed: boolean;
}
export type TodoId = ItemId;
export type TodoText = ItemText;
// 第4章の全機能を BaseItem から継承
export class TodoItem extends BaseItem implements ITodoItem {
constructor(id: TodoId, text: TodoText, completed: boolean = false) {
super(id, text, completed);
}
// 第4章のファクトリメソッドをそのまま維持
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);
}
// 第4章の状態変更メソッドをそのまま維持(戻り値の型のみ調整)
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);
}
// 第4章の等価性判定を BaseItem の仕組みを使って実装
equals(other: BaseItem): boolean {
return other instanceof TodoItem && this.baseEquals(other);
}
// 第4章の toString メソッドをそのまま維持
toString(): string {
const status = this._completed ? '✓' : '○';
return `${status} #${this._id}: ${this._text}`;
}
}
// 第4章との互換性を保つため、元の機能はすべて利用可能
// - プライベートフィールド(_id, _text, _completed)は BaseItem から継承
// - ゲッター(id, text, completed)は BaseItem から継承
// - 状態確認(isCompleted(), isIncomplete())は BaseItem から継承
// - 型安全性(instanceof チェック、null/undefined 対応)は BaseItem で実装
✅ 第4章からの変更点
- • 継承構造:`BaseItem` を継承するように変更
- • 共通機能:基本的な機能は `BaseItem` から継承
- • 機能の維持:第4章の全機能は引き続き利用可能
- • 型の調整:戻り値の型を `TodoItem` に調整
- • 等価性:`BaseItem` の `baseEquals` を活用
📋7. TaskItemの継承による実装
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);
}
// TaskItem特有のメソッド
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(other: BaseItem): boolean {
return other instanceof TaskItem && this.baseEquals(other);
}
toString(): string {
const status = this._completed ? '✓' : '○';
const prioritySymbol = {
high: '🔴',
medium: '🟡',
low: '🟢'
}[this._priority];
return `${status} ${prioritySymbol} #${this._id}: ${this._text}`;
}
}
🔄8. 異なるタイプの比較テスト
TodoItemとTaskItemが適切に区別されることをテストします。
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';
describe('アイテムタイプ比較のテスト', () => {
it('同じIDとテキストでもTodoItemとTaskItemは等価でないこと', () => {
const todo = TodoItem.create(1, 'TypeScriptでTDDを学ぶ');
const task = TaskItem.create(1, 'TypeScriptでTDDを学ぶ', 'high');
expect(todo.equals(task)).toBe(false);
expect(task.equals(todo)).toBe(false);
});
it('コレクション内で異なるアイテムタイプを区別すること', () => {
const todo = TodoItem.create(1, 'TDDを学ぶ');
const task = TaskItem.create(2, 'PRをレビューする', 'high');
const items = [todo, task];
expect(items[0]).toBeInstanceOf(TodoItem);
expect(items[1]).toBeInstanceOf(TaskItem);
});
});
🔄9. ポリモーフィズムの活用
💡ポリモーフィズム(多態性)とは?
📝 基本的な仕組み
異なる種類のクラス(`TodoItem`と`TaskItem`)を、 同じメソッド名で操作できる仕組みです。 両方とも`toggle()`や`complete()`というメソッドを持っているので、 どちらのクラスでも同じ書き方で操作できます。
🔧 実際の動作
`item.toggle()`と書いたとき、 `TodoItem`なら普通の表示で完了状態を切り替え、 `TaskItem`なら優先度マークも含めて表示を更新します:
✅ メリット
新しい種類のアイテムを追加しても、 既存のコード(`ItemManager`など)を変更する必要がありません。 共通のメソッド名を使っているからです。
共通の基底クラスを活用してポリモーフィズムを実現する実装例を見てみましょう。
import { BaseItem } from './baseItem';
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';
export class ItemManager {
private items: BaseItem[] = [];
addItem(item: BaseItem): void {
this.items.push(item);
}
completeItem(itemId: number): void {
this.items = this.items.map(item =>
item.id === itemId ? item.complete() : item
);
}
toggleItem(itemId: number): void {
this.items = this.items.map(item =>
item.id === itemId ? item.toggle() : item
);
}
getCompletedItems(): BaseItem[] {
return this.items.filter(item => item.isCompleted());
}
getIncompleteItems(): BaseItem[] {
return this.items.filter(item => item.isIncomplete());
}
getHighPriorityTasks(): TaskItem[] {
return this.items
.filter((item): item is TaskItem => item instanceof TaskItem)
.filter(task => task.isHighPriority());
}
getAllItems(): readonly BaseItem[] {
return [...this.items];
}
toString(): string {
return this.items.map(item => item.toString()).join('\n');
}
}
🧪10. 統合テスト
📊統合テスト(Integration Test)とは?
📝 基本的な違い
今まで書いてきたテストは「単体テスト」と呼ばれ、 1つのクラスが正しく動くかを確認していました。 統合テストは、複数のクラスを組み合わせて使ったときに 正しく動作するかを確認するテストです。
🔍 テストの範囲
単体テスト:`TodoItem`クラス単体の動作を確認
統合テスト:`ItemManager`が`TodoItem`と`TaskItem`を正しく管理できるかを確認
🎯 この章での目的
`TodoItem`と`TaskItem`という異なるクラスを、 `ItemManager`で一緒に管理できることを確認します。 ポリモーフィズムが実際に機能していることをテストで証明するのが目標です。
複数のアイテムタイプを統合的に管理するテストを実装しましょう。
import { ItemManager } from './itemManager';
import { TodoItem } from './todoItem';
import { TaskItem } from './taskItem';
describe('ItemManagerのテスト', () => {
let manager: ItemManager;
beforeEach(() => {
manager = new ItemManager();
});
it('TodoItemとTaskItemの両方を管理すること', () => {
const todo = TodoItem.create(1, 'TDDを学ぶ');
const task = TaskItem.create(2, 'PRをレビューする', 'high');
manager.addItem(todo);
manager.addItem(task);
expect(manager.getAllItems()).toHaveLength(2);
expect(manager.getHighPriorityTasks()).toHaveLength(1);
});
it('ポリモーフィズムでアイテムを切り替えること', () => {
const todo = TodoItem.create(1, 'TDDを学ぶ');
const task = TaskItem.create(2, 'PRをレビューする', 'high');
manager.addItem(todo);
manager.addItem(task);
manager.toggleItem(1);
const items = manager.getAllItems();
expect(items[0].isCompleted()).toBe(true);
expect(items[1].isCompleted()).toBe(false);
});
});
🚀コミットとCIの確認
git add .
git commit -m "feat: Implement TaskItem with intentional duplication, then refactor using inheritance"
git push
🎯この章のまとめ
この章では以下を学びました:
- • 意図的な重複の作成:素早い進展のためにコピー&ペーストを活用
- • リファクタリングのタイミング:重複が問題になった時点で解決
- • 継承による重複除去:共通基底クラスを使った設計改善
- • ポリモーフィズムの活用:異なる型を統一的に扱う仕組み
- • 原則を破る勇気:状況に応じて最適な戦略を選択する判断力
次の章では、テスト不足に気づいたときの対処法について学びます。