個人開発で AdMob の広告表示の出し分けロジックを少し直したとき、Antigravity のエージェントに「このモジュールのテストを書いて」と頼みました。返ってきたテストは十数ケース、すべて緑。安心して本番に出したところ、特定の条件でインタースティシャルが二重に出るバグが残っていました。
テストは全部通っていたのに、です。
あとから読み返すと、生成されたテストは「関数を呼んで例外が出ないこと」や「戻り値が truthy であること」ばかりを確かめていました。境界の手前と奥で挙動が変わる、肝心のところに踏み込んだアサーションが一つもなかったのです。
エージェントにテストを任せるほど、この「通るだけのテスト」をどう見抜くかが運用の要になります。テストの実効性そのものを測るミューテーションテストを使い、採用する前にふるいへかける。その手順を、動くコードとともに追っていきます。
なぜ「緑のテスト」だけでは信用できないのか
テストが緑であることは、二つのまったく違う状態を区別しません。一つは「実装が正しいから通っている」。もう一つは「テストが何も検証していないから通っている」。
カバレッジも同じ落とし穴を持ちます。行カバレッジ 100% は「その行が実行された」ことしか保証しません。実行されても、結果を誰も確かめていなければ、バグはすり抜けます。
AI が書いたテストは、この弱いアサーションに偏りがちです。プロンプトに明示しなければ、エージェントは「落ちないテスト」を最短で作ろうとします。tautological(同義反復的)なアサーション、たとえば計算結果をもう一度同じ式で求めて比べるようなテストすら、平然と緑になります。
必要なのは「テストが通ったか」ではなく「テストはバグを捕まえられるのか」を測る物差しです。それがミューテーションテストです。
ミューテーションテストが測っているもの
考え方はシンプルです。実装にわざと小さなバグ(ミュータント)を仕込み、テストがそれに気づいて落ちるかを見ます。
たとえば >= を > に変える、+ を - にする、return true を return false にする、&& を || にする。こうした一点改変を機械的に大量生成します。
各ミュータントについて、結果は二つに分かれます。
結果 意味 テストへの評価
killed(殺された) 改変によってどれかのテストが落ちた そのバグを捕まえられる良いテスト
survived(生存) 改変してもテストは全部緑のまま そこを誰も検証していない盲点
殺せたミュータントの割合がミューテーションスコアです。生存ミュータントは、そのまま「テストが見落としている挙動の一覧」になります 。冒頭の広告二重表示も、後から走らせると「二重防止フラグを反転させたミュータントが生存」という形で、はっきり浮かび上がりました。
最小構成で動かす
JavaScript / TypeScript なら Stryker が扱いやすいです。題材として、広告を出してよいかを判定する小さなモジュールを使います。
// adReady.js
export function canShowInterstitial ( state ) {
// 起動から once 表示済みなら、最低 60 秒空ける
if (state.lastShownAt !== null ) {
const elapsed = state.now - state.lastShownAt;
if (elapsed < 60_000 ) return false ;
}
// 課金ユーザーには出さない
if (state.isPremium) return false ;
return true ;
}
エージェントが最初に書いてきたのは、こういうテストでした。
// adReady.weak.test.js
import { describe, it, expect } from "vitest" ;
import { canShowInterstitial } from "./adReady.js" ;
describe ( "canShowInterstitial" , () => {
it ( "値を返す" , () => {
const result = canShowInterstitial ({
now: 100_000 , lastShownAt: null , isPremium: false ,
});
expect (result). toBeDefined ();
});
it ( "課金ユーザーでも落ちない" , () => {
expect (() =>
canShowInterstitial ({ now: 0 , lastShownAt: null , isPremium: true })
).not. toThrow ();
});
});
このテストは緑です。カバレッジも一見悪くありません。Stryker を入れて実効性を測ってみます。
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner vitest
// stryker.conf.js
export default {
testRunner: "vitest" ,
coverageAnalysis: "perTest" ,
mutate: [ "adReady.js" ] ,
reporters: [ "clear-text" , "html" ] ,
thresholds: { high: 90 , low: 70 , break: 70 } ,
} ;
npx stryker run
結果は厳しいものになります。
Mutation score: 27.27%
Survived mutants:
- adReady.js:4 `elapsed < 60_000` → `elapsed <= 60_000`
- adReady.js:4 `60_000` → `60_001`
- adReady.js:8 `if (state.isPremium) return false` → `return true`
- adReady.js:9 `return true` → `return false`
緑だったテストが、実際にはロジックのほとんどを検証していなかったことが、数字で見えます。break: 70 を超えられず、このコミットは CI で止まります。
生存ミュータントの読み方
生存ミュータントは、どれも「直すべきテストの設計図」です。
if (state.isPremium) return false が return true に変わっても生存した、というのは「課金ユーザーに広告を出さない」という最重要の仕様を、一つもアサートしていないという意味です。冒頭で私がやってしまった見落としと、構造はまったく同じでした。
弱いテストを、ミュータントを殺せる形に書き直します。
// adReady.strong.test.js
import { describe, it, expect } from "vitest" ;
import { canShowInterstitial } from "./adReady.js" ;
const base = { now: 1_000_000 , lastShownAt: null , isPremium: false };
describe ( "canShowInterstitial" , () => {
it ( "条件を満たせば表示できる" , () => {
expect ( canShowInterstitial (base)). toBe ( true );
});
it ( "課金ユーザーには表示しない" , () => {
expect ( canShowInterstitial ({ ... base, isPremium: true })). toBe ( false );
});
it ( "前回表示から 60 秒未満なら表示しない" , () => {
expect (
canShowInterstitial ({ ... base, lastShownAt: base.now - 59_999 })
). toBe ( false );
});
it ( "ちょうど 60 秒経過していれば表示する(境界)" , () => {
expect (
canShowInterstitial ({ ... base, lastShownAt: base.now - 60_000 })
). toBe ( true );
});
});
最後の「ちょうど 60 秒」のケースが、< と <= を取り違えるミュータントを殺します。境界ぴったりの値を一つ置くだけで、オフバイワンの改変が生存できなくなります。再実行するとスコアは 100% になり、ゲートを通過します。
エージェントループに組み込む
ここからが Antigravity を使う本題です。手で生存ミュータントを読み解くのは、対象が増えると現実的ではありません。生存ミュータントの一覧こそ、エージェントに渡すと効く入力です。
Stryker は機械可読な JSON レポートを出せます。
npx stryker run --reporters json
# → reports/mutation/mutation.json
このうち status が "Survived" の項目だけを抜き出し、ファイル・行・改変内容に絞ってエージェントへ渡します。
node -e '
const r = require("./reports/mutation/mutation.json");
const out = [];
for (const [file, f] of Object.entries(r.files)) {
for (const m of f.mutants) {
if (m.status === "Survived") {
out.push(`${file}:${m.location.start.line} ${m.mutatorName} → ${m.replacement}`);
}
}
}
console.log(out.join("\n"));
' > survived.txt
そのうえで、Antigravity のチャットエージェントには次のような指示を与えます。プロンプトの肝は、実装には触れさせないことです。
survived.txt に、現在のテストが見逃しているミュータントの一覧があります。各ミュータントを殺すテストケースだけを追加してください。adReady.js の実装は変更しないでください。テストを足したら npx stryker run を実行し、生存数が 0 になるまで繰り返してください。
実装を凍結したまま、生存ミュータントという具体的な的に向けてテストだけを足させる。この制約があると、エージェントは「通るだけのテスト」を量産する方向には進めません。殺すべき改変が一覧で与えられているので、アサーションは自然と挙動の核心に寄っていきます。
私自身の運用では、この一覧を渡す前後で、生成テストの質がはっきり変わりました。渡さないと弱いアサーションに流れ、渡すと境界値と否定パスを自分から拾ってくるようになります。
スコア 100% を目指してはいけない理由
一つだけ先に釘を刺しておきます。ミューテーションスコアを 100% に固定すると、かえって運用が壊れます。
原因は等価ミュータントです。改変しても挙動が一切変わらないミュータント、たとえば最終結果に影響しないループ上限の <=/< の違いや、デッドコードへの改変は、原理的にどんなテストでも殺せません。これらは「テストの盲点」ではなく「殺せないことが正しい」ミュータントです。
機械はこの等価性を完全には判定できません。そのため 100% を強制すると、エージェントは殺せないミュータントを殺そうとして、実装の意味に踏み込まない奇妙なアサーション、あるいは実装の細部に密着しすぎて壊れやすいテストを書き始めます。これは弱いテストとは別種の、しかし同じくらい厄介な劣化です。
私の運用では、生存ミュータントを一件ずつ見て「これは盲点か、等価か」を仕分け、等価と判断したものは Stryker の // Stryker disable コメントで明示的に除外しています。閾値は 80〜90% に置き、残りは人間が説明責任を持つ。この線引きがあると、エージェントに渡す的が「本当に潰すべきもの」だけに絞られます。
どこまでやるか — 閾値と差分ミューテーション
ミューテーションテストは重い処理です。リポジトリ全体に毎回かけると時間がかかりすぎて、運用が続きません。現実的な落としどころを二つ挙げます。
一つ目は、差分だけを対象にすることです。Stryker は変更されたファイルだけを評価できます。
npx stryker run --since main
プルリクで触ったコードにのみミューテーションを走らせれば、数十秒で終わります。新しく書いたコードの実効性だけを守る、という割り切りです。
二つ目は、閾値を一律にしないことです。課金・認証・広告の表示条件のように、間違えると損失に直結するモジュールはスコアを高く要求し、ログ整形のような壊れても軽微な箇所は緩めます。mutate のパターンを分けるか、ディレクトリ単位で設定を持つのが扱いやすいです。
全部を 100% にする必要はありません。狙いは「エージェントが書いた緑のテストを、無条件には信用しない」という運用上の歯止めを一つ持つことです。
次の一歩として、いま一番壊したくないモジュールを一つだけ選び、そこに stryker run --since main を CI のチェックとして足してみてください。生存ミュータントが一覧で出てきた瞬間、テストの何が足りていなかったかが、言葉ではなく数字で分かります。
お読みいただきありがとうございました。同じように AI にテストを任せ始めた方の、最初のふるいになれば幸いです。