12k
All articles

エージェントがコードを書く時代に、TDDはまだ意味があるのか?

テスト駆動開発でAIエージェントをどう管理するかを解説。先にVitestテストを書き、改変を防ぎ、CIとレビューで実装を検証します。

OpenReplay Team
OpenReplay Team
エージェントがコードを書く時代に、TDDはまだ意味があるのか?

答えは「はい」です。むしろ以前より重要だと言えます。ただし、儀式としてではなく契約としてです。コーディングエージェントが実装を書く場合、先に書いたテストが仕様になります。テストはコードが存在する前に「完了」を定義し、エージェントが実際には終わっていないタスクを完了と報告したときに、それを見抜きます。

Claude Code、Cursor、GitHub Copilot に実装を任せている開発者の多くは、次のような場面に遭遇したことがあるはずです。エージェントが「すべてのテストが通りました」と報告したのに、機能を初めて使った瞬間に壊れる。あるいは、新しいコードに合わせてテストファイルがこっそり書き換えられている。

本記事では、エージェント向けのテストファーストのワークフローを解説します。実際の Vitest ファイル、それと組み合わせる具体的な指示文、エージェントがそれをすり抜ける手口、そしてその抜け穴をふさぐガードレールを取り上げます。

要点

  • エージェントが作業を始める前に書いたテストは、実行可能な仕様として機能します。エージェントの仕事はテストを通すことであり、「通る」とは何かを決めることではありません。
  • コードの後に書かれたテストは、コードがすでにしていることを記述しがちです。そのため、構造上必ず通り、何も検証しません。
  • 「すべてのテストが通った」というエージェントのメッセージは主張にすぎません。プッシュされたコミットに対する CI の実行結果こそが証拠です。
  • 1 つの diff の中で実装とそれを評価するアサーションの両方が変更されていれば、エージェントは自分の答案を自分で採点したことになります。テストの変更はコードの変更とは分けてレビューしましょう。
  • 使い捨てのプロトタイプや純粋に見た目だけの調整では、テストファーストは省略してかまいません。こうしたケースでは、実際に見るまで期待する結果を書き出せないからです。

Red-Green-Refactor とは?

Red-Green-Refactor は TDD のループです。まず 1 つの振る舞いを記述する失敗するテストを書き(Red)、次にそれを通すための最小限のコードを書きます(Green)。テストスイートがすべてグリーンの状態で、振る舞いを変えずにコードを整理します(Refactor)。React の例を交えたより詳しい復習は、TDD vs BDD in React を参照してください。

AI を使った TDD のワークフローはどう機能するのか?

AI を使った TDD は、人間が失敗するテストを書く(または承認する)、エージェントにはテストを変更せずに通すよう指示する、という形で機能します。要件を保持するのはテストであり、エージェントが提供するのは実装だけです。以下の例では Vitest 5 を使用します。

まずスタブを用意し、テストファイルが実在するものを import できるようにします。

// src/cart/applyCoupon.ts
export type Coupon =
  | { code: string; kind: 'percent'; value: number }
  | { code: string; kind: 'fixed'; valueCents: number };

export function applyCoupon(
  subtotalCents: number,
  code: string,
  coupons: Coupon[],
): number {
  throw new Error('not implemented');
}

次に仕様を書きます。

// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';

const coupons: Coupon[] = [
  { code: 'SAVE10', kind: 'percent', value: 10 },
  { code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];

describe('applyCoupon', () => {
  it('takes a percentage off the subtotal', () => {
    expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
  });

  it('takes a fixed amount off the subtotal', () => {
    expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
  });

  it('never returns a negative total', () => {
    expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
  });

  it('matches codes case-insensitively', () => {
    expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
  });

  it('rejects unknown codes', () => {
    expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
  });
});

スタブが存在し例外を投げるため、5 つのテストはすべて import の欠落ではなく実行時に失敗します。これがクリーンな Red フェーズです。最後のテストは少し注意が必要です。スタブは確かに例外を投げますが、そのメッセージは 'Unknown coupon' と一致しないため、正しい理由で失敗しています。各テストは、パーセント割引と定額割引の違い、0 を下限とすること、大文字・小文字の扱いといったルールを 1 つずつ固定しています。テストがなければ、エージェントはこれらを推測するしかありません。

そのうえで、明確な制約を付けてエージェントに渡します。

Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.

Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.

「stop and explain(作業を止めて説明する)」という一文は、テストを変更する以外の正当な逃げ道をエージェントに与えます。出力を貼り付けさせれば手間は 1 つ省けますが、それは何の証明にもなりません。このループに合うプロンプトの習慣については、5 Tips and Tricks for AI-Assisted Coding で詳しく紹介しています。

エージェントとのテストファーストはどこで破綻するのか?

エージェントはグリーンになる最短ルートを探しがちで、仕様を完全に素通りするルートが 3 つあります。

テストの編集・削除。実装が難しいとき、期待値を満たすより期待値を変えるほうが簡単です。toBe(1800) の書き換え、新たに追加された .skip、消えたテストファイル。いずれも要件が取り除かれた状態でグリーンの結果を生みます。

弱いアサーション。コードの後に書かれたテストは、コードが「すべきこと」ではなく「していること」を確認しがちです。

// Confirms the code ran, not that it's right
it('applies coupon', () => {
  const result = applyCoupon(2000, 'SAVE10', coupons);
  expect(result).toBeDefined();
  expect(typeof result).toBe('number');
});

// Pins the behaviour
it('takes 10% off with SAVE10', () => {
  expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});

弱い toBeDefined() のテストは、applyCoupon が 0 を返しても、2000 を返しても、999999 を返しても通ります。だからこそ書く順番が重要なのです。エージェントが自分のコードの後に書くテストは、前者のような形になりがちです。エージェントはこうした浅いチェックを大量に並べてスイートを水増しすることもあります。このパターンについては the smoke tests agents keep writing で取り上げています。

テスト対象そのもののモック。テスト対象のモジュールをモックすると、テストが検証するのはモックになり、実際の実装が何をしていてもテストはグリーンのままです。

import { vi } from 'vitest';

vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.

Vitest は vi.mock を巻き上げ(hoist)て import より前に配置するため、実際のコードが読み込まれる前にファクトリーがモジュールを置き換えます。本物の applyCoupon は一度も実行されません。モックすべきなのは境界(ネットワーク、ストレージ、時刻)であり、仕様を定めようとしているユニットそのものではありません。

仕様を固定するためのガードレールとは?

ガードレールが機能するのは、それが機械的に強制される場合です。指示によってテストの改ざんは減らせますが、指示で防ぎきれないものは CI とレビューで捕捉します。

テストの diff は分けてレビューする。*.test.ts 配下の変更は、実装より先に、独立したレビューとして読みます。GitHub では、CODEOWNERS とコードオーナーによるレビューの必須化を使えば、テストパスへの変更に対して必ず特定の担当者の承認を得るようにできます。

エージェントによるテスト編集をブロック、または禁止する。

「完了」の判断は CI に任せる。プッシュされたコミットに対して npx vitest run を実行します。CI 環境では、--allowOnly を渡すか allowOnly: true を設定しない限り、Vitest は .only を検出すると実行を失敗させます。ただし .skip は検出しないため、実装ブランチには diff ガードを追加します。

changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
  echo "Test files changed on an implementation branch:"
  echo "$changed"
  exit 1
fi

origin/main を解決できるだけの履歴をチェックアウトしておく必要があります。

アサーションが振る舞いを検証しているか確認する。各アサーションについて、「間違った実装でも通ってしまわないか」を問いましょう。厳密な値、送出されるエラー、目に見える出力は、リファクタリングを経ても有効であり続けます。内部ヘルパーの呼び出し回数や型チェックはそうではありません。

クリーンなスイートにも限界はあります。ユニットテストが通ることが示すのは、誰かが書き出したケースをコードが処理できるということだけです。実際のユーザーがブラウザ上でフローを完了できることまでは示しません。リリース済み機能のセッションリプレイを見ると、CI がグリーンなのにチェックアウトが壊れている、ということは今でも起こり得ます。

TDD が割に合わないのはどんなときか?

期待する結果を事前に言語化できない場合、テストファーストはコストが見返りを上回ります。

状況テストファーストを省略する理由
使い捨てのプロトタイプコードはある問いに答えるためのもので、その後は削除される
UI の見た目の調整(余白、色、レイアウト)正しい結果は見て判断するものであり、まだアサートできる内容がない

プロトタイプが生き残った場合は、エージェントがそれを本番コードとして固める前にテストを書きましょう。

まとめ

エージェントがコードを書くとき、仕様はあなたにしか用意できない部分であり、先に書くテストがそれを実行可能なものにします。次にエージェントにタスクを任せるときは、まず失敗する Vitest ファイルを書き、テストを編集しないよう指示を与え、CI とテスト diff の個別レビューの両方を通過してからマージしてください。

よくある質問

エージェントが追加したスキップ済みテストを linter で検出できますか?

はい。@vitest/eslint-plugin パッケージには、test.skip、it.skip、describe.skip を検出する vitest/no-disabled-tests ルールと、.only を検出する vitest/no-focused-tests ルールがあります。推奨設定では無効化されたテストは警告扱いにとどまるため、このルールを 'error' に設定し、CI で ESLint を実行してください。Jest プロジェクトでは、eslint-plugin-jest に同名のルールがあります。lint はエイリアス経由の呼び出しを見逃すことがあるため、git の diff ガードも併用しましょう。

失敗するテストを人間ではなく AI エージェントに書かせてもよいですか?

はい。ただし、テストを書く工程と実装を書く工程を分けることが条件です。あるエージェントセッションにスタブに対するテストの下書きを作らせ、アサーションを自分でレビューし、実装を始める前にコミットします。実装を担当するセッションには、変更が許されないテストだけを見せるようにします。同じセッションが両方を書くと、テストは要件ではなく、そのセッション自身のコードを記述したものになってしまいます。

テストを先に書かなくても、詳細な仕様書があれば十分ではありませんか?

いいえ。文章の仕様書やドキュメントコメント付きのスタブは、何を作るべきかをエージェントに伝えますが、結果がそれに沿っているかを検証するものは何もありません。失敗するテストは、もっともらしく見えるだけの実装では満たせません。通るか、通らないかのどちらかです。両方を使いましょう。要件と制約はプロンプトや指示ファイルに記述し、必ず成り立つべきケースは、CI がすべてのコミットで実行するテストとしてコード化します。

エージェントが「テストが間違っている」と言ってきたらどうすればよいですか?

エージェントの説明を自分で確認し、本当にテストが間違っているなら、実装ブランチの外で修正します。修正したテストは、独立したコミットまたはプルリクエストとして main にマージし(CODEOWNERS を使っている場合はそのレビューを経て)、その後に実装ブランチをリベースします。diff ガードはブランチを origin/main と比較するため、すでに main にマージ済みのテスト修正では引っかかりません。そしてエージェントが仕様を編集することは一切ありません。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.