AIの時代のテスト:AIを使ってテストを書く
AI生成テストはカバレッジを上げてもバグを見逃すことがあります。要件ベースのプロンプト、ミューテーション検査、モック確認で防ぎます。
テスト対象のコードを実行して期待値を得たテストは、コードがすでに行っていることを追認するだけです。バグがすでに存在していれば、そのテストはバグを固定化してしまいます。
アシスタントでテストを生成したことがあるなら、このパターンには見覚えがあるかもしれません。PRで40個のテストが追加され、カバレッジが2ポイント上がり、CIはグリーン、それでも同じ請求処理のバグは木曜日に本番へ到達する。そのテストには、コードに異議を唱える手段が最初からなかったのです。
本記事では、生成されたテストが「何も守らないままパスする」ときに取る3つの形と、それを是正する3つのチェック、すなわち「実装ではなく要件からプロンプトを書く」「意図的にコードを壊す」「アサーションの前にモックを読む」を扱います。
要点
- 行カバレッジが記録するのは「どの行が実行されたか」であり、それらの行が誤った結果を返したときにアサーションが失敗するかどうかは一切記録しない。
- 手計算で求めた期待値と、関数の出力からコピーした期待値は、diff上では見分けがつかない。異なるのは出所だけであり、だからこそ生成されたテストはレビューを通過する。
- モデルに関数名を渡してテストを書かせると、モデルが参照できるのは実装だけになる。ビジネスルールを与えれば、コードに異議を唱えうる真実の源(source of truth)を与えることになる。
- 生成されたテストスイートを最速で検証する方法は、ソース中の定数を1つ変える、あるいは比較演算子を1つ反転させて再実行することである。すべてグリーンのままなら、その行は何にも守られていなかった。
- テスト対象だと主張している関数そのものをモックで置き換えているテストは、モックに設定した戻り値を検証しているにすぎない。そうしたテストは修理するのではなく削除すべきである。
なぜカバレッジは上がるのにバグは出荷され続けるのか?
カバレッジが測っているのは実行であって、検証ではありません。Jestのカバレッジプロバイダ(デフォルトはBabel/Istanbul、V8も選択可)とVitestのカバレッジプロバイダ(デフォルトはV8、Istanbulも選択可)は、いずれも同じ4つの指標(statements、branches、functions、lines)を報告します。どちらも、アサーションが失敗しうるかどうかは評価しません。関数を呼び出して、返ってきたものをそのままアサートするテストは、正しいアサーションを持つテストとまったく同じように、通過したすべての行をカバーします。
だからこそ、数値は上がり続けるのにバグは出荷され続けるのです。生成されたテストは安価なので、テスト下で実行されるコードは増えます。しかし実装から導出されたテストは、実装のあらゆる欠陥を共有します。そしてカバレッジレポートには、それを示す列は存在しません。
失敗その1:コードがすでに返している値をアサートするテスト
ソースだけを与えられたモデルにとって、期待値のオラクル(判定基準)はソースしかありません。モデルは関数について推論する(あるいは実行する)ことで出力を観測し、その出力をアサーションのリテラルとして書き込みます。関数が間違っていれば、リテラルも同じ形で間違います。
コピペのバグを含む割引関数を例に取ります。
// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
return price * (1 - rate);
}
以下の describe/it/expect APIはJestとVitestの両方で動作します。Jestはこれらをグローバルに公開していますが、Vitestではimportするか globals: true が必要です。
import { calculateDiscount } from "./discount";
// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
expect(calculateDiscount(100, "silver")).toBe(75);
});
// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
expect(calculateDiscount(100, "silver")).toBe(85);
});
1つ目のテストはバグのある関数に対してパスします。2つ目は失敗しますが、それこそが狙いです。diff上では、75 と 85 はどちらも同じくらい妥当に見えます。一方が価格ルールから算出され、もう一方が関数の出力からコピーされたものであることを、構文はレビュアーに何も伝えません。唯一の防御策は、「その数値はどこから来たのか」を問うことです。
失敗その2:生成されたテストはハッピーパスしかカバーしない
生成されたテストスイートは、プロンプトに記述されたことをテストします。そして関数名を挙げただけのプロンプトは、その通常動作しか記述していません。生成スイートが最も頻繁に省くケース(不正な入力、到達不能な依存、タイムアウト)は、プロンプトが一度も言及しなかったものであり、モデルにはそれらを書く理由がなかったのです。
結果として、正しい形式の入力と有効なティアに対するほぼ同一のテストが5本でき、マイナスの価格、未知のティア文字列、応答しない上流サービスに対するテストは1本もない、という状態になります。そしてそれらこそが、テストされないまま本番に到達する経路です。開発者が手動で最も動かさない経路でもあるからです。
失敗その3:テスト対象のユニットがモックされている
テスト対象だと主張している関数をモックで置き換えているテストは、その関数の挙動ではなく、モックに設定した戻り値を検証しています。モデルが積極的にモックを使うのは、モックによってテストが確実にパスするからです。データベース、ネットワーククライアント、そしてテスト対象のサービスまでモックしたテストは、どんな実装の下でもパスします。
このパターンは、依存を注入する形にすると見えやすくなります。この形なら jest.mock と vi.mock の差異を例から排除できます。
// cart.ts
import type { calculateDiscount } from "./discount";
export function cartTotal(price: number, tier: "silver" | "gold",
discount: typeof calculateDiscount): number {
return Math.round(discount(price, tier) * 100) / 100;
}
// cart.test.ts
import { cartTotal } from "./cart";
// Before: the fake is the thing being checked.
it("returns discounted total", () => {
const fake = () => 85;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
const fake = () => 85.004999;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
「Before」のテストは、cartTotal が引数を無視して85を返すだけの実装であってもパスします。「After」のテストは、丸め処理が実行されて初めて正しい結果になる値をフェイクに返させているため、スタブではなく関数そのものを観測しています。
修正その1:AIによるユニットテスト生成には、コードではなく要件を与える
モデルに関数名を渡してテストを書かせると、モデルが参照できるのは実装だけです。ビジネスルールを文章化した要件として与えれば、コードには供給できないもの、すなわちコードに異議を唱えうる仕様を与えることになります。
弱いプロンプト:
Write unit tests for calculateDiscount.
より強いプロンプト:
Write unit tests for calculateDiscount in discount.ts.
Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.
Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.
2つ目のプロンプトは expect(calculateDiscount(100, "silver")).toBe(85) を生成します。85はルールが定める値だからです。そしてこのテストは、バグのある関数に対して初回実行で失敗します。ルールとして読めるテスト名(「charges 85 for a 100 silver order」)は、テストスイートを仕様としてレビュー可能にする効果もあります。このプロンプトはモデルがソースを無視することを保証するものではありませんが、ソースよりも優先される真実の源をモデルに与えます。
修正その2:コードを壊して、レッドになるか見る
生成されたテストスイートを最速で検証する方法は、テスト対象コードの定数を1つ変える、あるいは比較演算子を1つ反転させて、テストを再実行することです。すべてグリーンのままなら、そのスイートはそのコードを守っていなかったということです。
1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
If nothing fails, no test asserts the silver rate.
ミューテーションテストはこれを自動化します。StrykerJS(@stryker-mutator/core)はソースに小さな変更を注入し、そのたびにスイートを再実行して、ミューテーションスコアを報告します。これは、テストが捕捉したミュータント(テストが失敗した、または実行がタイムアウトした)を有効なミュータント数で割った値です。サポートされているミューテータは、比較演算子の反転、ブール値の否定、算術演算子の入れ替え、文字列リテラルの空化、ブロック本体の削除などを行います。ランナープラグインはJest向けとVitest向けが存在します。導入前に、インストール済みバージョンとの互換性を確認してください。
修正その3:アサーションの前にモックを読む
生成されたテストのdiffは、次の順序でレビューします。何がフェイクにされているか、期待値はどこから来たか、そして最後に何がアサートされているか。何がフェイクにされているかは、そのテストが何を観測できるかを決めます。期待値の出所は、そのアサーションがコードに異議を唱えられるかどうかを決めます。アサーションそのものは、最も情報量の少ない行です。
- テスト対象のユニットを置き換えるモックがある場合:そのテストは削除する。
- 期待値が関数呼び出しになっている、あるいは裏付けとなるルールのないリテラルである場合:要件から計算し直す。
- モックが本物の境界(データベース、ネットワーク、時計)にのみ使われていて、アサーションがユニット自身の出力に対して行われている場合:そのまま残す。
判断は手放さない
AIはテストの機械的な部分では頼りになります。フィクスチャ、セットアップとティアダウン、ほぼ同一のケースをパラメータ化したテーブル、非同期エラーパスのボイラープレートなどです。一方で、どの挙動をアサートする価値があるかを決める部分では頼りになりません。その判断はコードではなく要件の中にあるからです。足場は生成させたうえで、diffの中で最も重要なテストを1つ取り上げ、そのテストが守るべき行を壊し、マージ前にレッドになることを確認してください。
FAQ
ミューテーションテストはコードカバレッジの代わりになりますか?
いいえ。両者は異なるギャップを報告します。カバレッジは、どのテストでも実行されなかった行を教えてくれます。ミューテーションテストは、実行された行のうちアサーションに守られていない行を教えてくれます。StrykerJSは両方を報告します。実行されないコード中のミュータントはNo coverage状態になり、実行されるコード中のミュータントで全テストが依然パスするものはSurvivedになります。ミューテーションスコアはどちらも未検出として数えるため、カバレッジが低ければスコアはそのぶん直接下がります。
StrykerJSはVitestやJestで動作しますか?
はい、ランナープラグイン経由で動作します。Vitestの場合は@stryker-mutator/vitest-runnerをインストールし、Stryker設定のtestRunnerをvitestに設定します。このプラグインはVitest本体を同梱していないため、対応する最も古いVitestリリースはプラグインのpackage.jsonで確認してください。VitestのBrowser Mode経由で実行されるテストは、このランナーの対応範囲外です。Jestの場合は@stryker-mutator/jest-runnerを使います。Vitestランナーの下では、StrykerはVitest自身のカバレッジ収集をオフにし、各ミュータントの実行を最初に失敗したテストで打ち切ります。ミュータントを倒すには1件の失敗で十分だからです。
アサーションがまったく含まれていないAI生成テストを検出するには?
アサーション数がゼロのテストをフレームワーク側で失敗させます。Vitestでは設定でexpect.requireAssertionsを有効にするか、CLIで--expect.requireAssertionsを渡します。expectを一度も呼ばずに終わるテストは失敗します。Jestではテスト本体の中でexpect.hasAssertions()を呼ぶか、beforeEachフックから呼んで全体に適用します。ただしどちらの設定も、アサーションが失敗しうるかどうかまでは検査しないため、モック自身の戻り値をアサートしているテストは依然としてパスします。
AIが生成したスナップショットテストは信頼できますか?
レビューなしでは信頼できません。現在の出力から記録されたスナップショットは、記録時点で生成されたもの(バグを含む)をコードが出し続けることだけをアサートします。これはコピーされたリテラルと同じトートロジーです。しかも再記録はフラグ1つで可能です。jest -uやvitest -uは失敗したスナップショットをすべて書き換えます。CIでは、Jestの--ciフラグにより、新規スナップショットを黙って書き込む代わりに失敗させられます。スナップショットのdiffは、以前の出力ではなく要件に照らしてレビューしてください。