AIエージェントとTDD — テスト駆動でエージェントを導く
AIコーディングエージェントにTDD(テスト駆動開発)を強制することで、コード品質と予測可能性を大幅に向上させる手法を解説する
なぜAIエージェントにTDDが必要か
AIエージェントはデフォルトで実装ファーストの開発を行う。テストは後回しにされ、エッジケースは無視され、「動いているように見える」コードが生成される。
TDDはこの問題を根本的に解決する。テストが仕様書として機能し、エージェントの実装を正しい方向に導く。
TDDなし: エージェント → 実装 → (テストを忘れる) → 完了宣言
TDDあり: テスト(Red) → 最小実装(Green) → リファクタ(Refactor) → 確実な完了
TDDがAIエージェントと相性が良い理由は3つある。
- 自然な分割: 複雑な問題を小さく検証可能な単位に分解する。エージェントのステップバイステップ処理と合致する
- 真実の源泉: テストスイートが「正しい動作」の定義になり、エージェントが脱線しにくくなる
- 即時フィードバック: テストの成功/失敗がエージェントに明確なシグナルを与える
コンテキスト汚染の問題
単一のエージェントでTDDを行うと、コンテキスト汚染が発生する。テストを書くエージェントが実装の計画を既に持っているため、テストが「実装を追認する」だけのものになりがちだ。
<!-- 単一エージェントの問題 -->
エージェント: 「この機能はこう実装しよう」(実装計画を立てる)
↓
エージェント: 「テストを書こう」(実装計画に沿ったテストを書く)
↓
結果: テストが実装のミラーになり、本来検出すべきバグを見逃す
解決策はサブエージェントによるフェーズ分離だ。
サブエージェントによるRed-Green-Refactorの実装
アーキテクチャ
3つの専門エージェントがそれぞれ独立したコンテキストで動作する。
Orchestrator(親エージェント)
├── tdd-test-writer → Red フェーズ
├── tdd-implementer → Green フェーズ
└── tdd-refactorer → Refactor フェーズ
Red フェーズ: テストライター
テストライターは実装計画を見ない。ユーザーの要件だけを入力として受け取る。
<!-- .claude/agents/tdd-test-writer.md -->
---
name: tdd-test-writer
description: "要件からテストを書く。実装は見ない。"
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
---
## 役割
ユーザーの要件に基づき、失敗するテストを書く。
## 制約
- 実装コードを参照しない
- モック実装やスタブを作成しない
- テストが確実に失敗することを確認する(npm run test で検証)
## 出力
- テストファイルのパス
- テスト実行結果(失敗を確認)
Green フェーズ: 実装者
実装者は失敗するテストだけを入力として受け取る。
<!-- .claude/agents/tdd-implementer.md -->
---
name: tdd-implementer
description: "失敗テストを通す最小限の実装を書く。"
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
---
## 役割
失敗しているテストを通す最小限のコードを書く。
## 制約
- テストが要求する以上の機能を実装しない
- 既存のテストを壊さない
- テストが全件パスすることを確認する(npm run test で検証)
## 出力
- 変更したファイルのパス
- テスト実行結果(成功を確認)
Refactor フェーズ: リファクタラー
リファクタラーは実装済みのコードとテスト結果を入力として受け取る。
<!-- .claude/agents/tdd-refactorer.md -->
---
name: tdd-refactorer
description: "テストが通る状態を維持しながらリファクタリングする。"
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
---
## 役割
以下のチェックリストに基づきリファクタリングを行う:
- 共通ロジックの抽出
- 条件式の簡素化
- 命名の改善
- 重複の除去
## 制約
- テストを変更しない
- リファクタリング後もテストが全件パスすること
- 改善が不要な場合は「リファクタリング不要」と判断理由を返す
## 出力
- 変更内容の要約
- テスト実行結果(成功を確認)
実践例: ワークアウト詳細機能
ユーザーの要件: 「過去のワークアウトをクリックしたら、エクササイズとセットの詳細を表示する」
Red(テストライター)
// workout-detail.test.ts
describe('WorkoutDetailView', () => {
it('should navigate to detail page on workout click', async () => {
const app = await createTestApp();
const workoutCard = app.getByTestId('workout-card-1');
await app.user.click(workoutCard);
expect(app.router.currentRoute.value.path)
.toBe('/workouts/d747077d-...');
});
it('should display exercises for the workout', async () => {
const app = await createTestApp({ route: '/workouts/d747077d' });
expect(app.getByText('ベンチプレス')).toBeInTheDocument();
expect(app.getByText('3 sets')).toBeInTheDocument();
});
});
テスト実行結果: FAIL — ルートもコンポーネントも存在しない。
Green(実装者)
最小限の実装:
WorkoutDetailView.vueを作成/workouts/:idルートを追加- クリックハンドラーを実装
テスト実行結果: PASS
Refactor(リファクタラー)
改善:
useWorkoutDetailコンポーザブルを抽出(データ取得ロジックの再利用)formatDuration/formatDateをlib/formatters.tsに集約- キーボードナビゲーションを追加
テスト実行結果: PASS(既存テストが引き続き通ることを確認)
Hook による TDD 強制
エージェントにTDDを「お願い」するだけでは不十分だ。Hook で強制する。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx vitest run --reporter=verbose 2>&1 | tail -20"
}
]
}
]
}
}
ファイル編集のたびにテストが自動実行される。テストが落ちている状態が即座にフィードバックされ、エージェントは修正を余儀なくされる。
テストヘルパーの重要性
エージェントがテストのたびにセットアップを再発明するのを防ぐ。共通のテストヘルパーを用意する。
// test/helpers/create-test-app.ts
export async function createTestApp(options?: {
route?: string;
initialState?: Record<string, unknown>;
}) {
const router = createRouter({ /* ... */ });
const pinia = createTestingPinia({ /* ... */ });
if (options?.route) {
await router.push(options.route);
}
const user = userEvent.setup();
const utils = render(App, {
global: { plugins: [router, pinia] },
});
return { ...utils, router, user };
}
このヘルパーが存在することで、各フェーズのエージェントが一貫したテスト環境を使える。
実践ポイント
- 明示的にTDDを宣言する: 「TDDで実装して」と明確に伝える。暗黙の期待は守られない
- テストファイルを先に作る: 空のテストファイルを先に作成し、エージェントに「このファイルにテストを書いて」と指示する
- サブエージェント分離のコスト: セットアップに約2時間かかるが、以降のすべての開発で品質が安定する
- 段階的な導入: 最初は単一エージェントで「テストを先に書く」ルールから始め、慣れたらサブエージェント分離に進む
- テストカバレッジをゲートにする: CIでカバレッジ閾値を設け、未達の場合はPRをブロックする