緑のテストが守ってくれなかった朝
ある修正をリリースした数日後、以前直したはずの不具合が同じ形で戻ってきたことがあります。テストは全部グリーン、カバレッジレポートも 90% 台。なのに、本番では割引計算が税抜き価格に対してかかってしまっていました。手元でテストを走らせ直しても、やはり全部通ります。
調べていくと、原因はテストの本数でもカバレッジの数字でもありませんでした。Antigravity のエージェントに「この関数のテストを書いて」と頼んで生成されたテスト群が、コードを通過はしているのに、肝心の振る舞いを一つも検証していなかったのです。実行はされる、だから行カバレッジは上がる。けれど、ロジックが間違っていても誰も気づけない。テストが「実行された」ことと「検証している」ことは別物だ、という当たり前を、回帰バグの形で突きつけられました。
この記事は、AI に書かせたテストが静かに空回りする現象を、ミューテーションテストという物差しで可視化し、運用で立て直すための実務メモです。題材は Vitest と Stryker Mutator ですが、考え方は Jest でも同じです。
なぜ AI 生成テストは「通るのに守らない」のか
エージェントは、与えられたコードを読み、それを通すテストを生成します。ここに落とし穴があります。「通すテスト」を最短で作ろうとすると、振る舞いを固定するのではなく、現状の出力をそのまま追認するテストになりがちなのです。私が繰り返し踏んだのは、次の3類型でした。
類型 症状 なぜ回帰を止められないか
モック過多 依存をすべてモックし、戻り値も固定する テストしているのが実装ではなくモックの設定値になり、本物のロジックを一行も通らない
トートロジー expect(result).toBe(result) 的に、計算結果をそのまま期待値に流用実装が間違っても期待値も一緒に間違うので、永遠に一致する
スナップショット追認 初回出力をスナップショットとして固定し、以後それと比較 誤った出力をそのまま正解として焼き付けてしまう
トートロジーは特に見つけにくいものです。例えば割引関数のテストで、エージェントが次のように書くことがあります。
import { describe, it, expect } from 'vitest'
import { applyDiscount } from '../src/cart/discount'
it ( '割引が適用される' , () => {
const price = 1000
const rate = 0.1
// ❌ 期待値を実装と同じ式で計算している(空回り)
const expected = price - price * rate
expect ( applyDiscount (price, rate)). toBe (expected)
})
このテストは緑になります。しかし applyDiscount の中身が税抜きに対して割引をかけていようが、税込みにかけていようが、expected を同じ式で算出している限り常に一致します。検証しているのは「同じ式を二回書いたら同じ答えになる」という事実だけで、仕様は一文字も守っていません。正しくはこう書くべきでした。
it ( '税抜き価格に対して10%割引が適用される' , () => {
// ✅ 期待値は実装に依存しないリテラルで固定する
expect ( applyDiscount ( 1000 , 0.1 )). toBe ( 900 )
})
it ( '割引率0で価格が変わらない' , () => {
expect ( applyDiscount ( 1000 , 0 )). toBe ( 1000 )
})
it ( '割引率1で価格が0になる' , () => {
expect ( applyDiscount ( 1000 , 1 )). toBe ( 0 )
})
期待値を実装の式から切り離し、人間が手計算した定数で固定する。これだけで、ロジックがずれた瞬間にテストが赤くなります。AI に任せるときも、この「期待値はリテラルで」という制約を最初に渡すかどうかで結果が大きく変わります。
物差しを変える — カバレッジではなくミューテーションスコア
行カバレッジは「コードが実行されたか」しか測りません。空回りするテストでも実行はされるので、カバレッジは平気で 90% を超えます。私が信用するようになったのはミューテーションスコアです。
ミューテーションテストは、ソースコードにわざと小さな変異(mutant)を注入します。- を + に、< を <= に、return true を return false に、といった具合です。そして既存のテストを走らせ、その変異を検知してテストが赤くなれば「殺せた(killed)」、変異を入れても全部緑のままなら「生き残った(survived)」と数えます。生き残った変異こそ、テストが守れていない箇所です。
Stryker Mutator の最小構成はこうです。
// stryker.config.mjs
export default {
testRunner: 'vitest' ,
coverageAnalysis: 'perTest' ,
mutate: [ 'src/cart/**/*.ts' , '!src/**/*.test.ts' ] ,
reporters: [ 'html' , 'clear-text' , 'json' ] ,
thresholds: { high: 80 , low: 60 , break: 50 } ,
}
走らせると、行カバレッジとは別の景色が見えます。先ほどのトートロジーなテストでは、Stryker が price - price * rate を price + price * rate に変えても、price * rate を price / rate に変えても、テストは全部緑のまま。つまり全部 survived です。私の最初の実測では、行カバレッジ 91% の関数群でミューテーションスコアが 47% しかありませんでした。半分以上の変異がすり抜けていた、ということです。この乖離こそが「カバレッジ劇場」の正体でした。
Antigravity でテストを書かせるときの渡し方
エージェントは指示の粒度で出力が変わります。「テストを書いて」ではなく、空回りを禁じる制約を最初から渡すのが効きました。私が定型にしている依頼の骨子はこうです。
@agent src/cart/discount.ts の applyDiscount にテストを書いて。
制約:
- 期待値は実装の式を再利用せず、手計算したリテラルで固定する
- 境界値(rate=0, rate=1, 負数, 価格0)を必ず含める
- 依存のモックは最小限。純粋関数はモックせず実値で検証する
- スナップショットは使わない
生成されたテストは、そのまま信用せずミューテーションスコアで受け入れ検査します。survived mutant が出たら、その変異内容をエージェントに戻すのが速い。
@agent Stryker で以下の mutant が survived だった。
これを kill するテストを追加して(期待値はリテラルで):
- discount.ts:12 「price * rate」→「price / rate」
- discount.ts:15 「>= 0」→「> 0」
変異の具体を渡すと、エージェントは「どの分岐が検証されていないか」をピンポイントで埋めてきます。漠然と「カバレッジを上げて」と頼むより、生き残った変異を名指しするほうが、空回りしないテストが返ってきました。
モックとアサーションの比率を監査する
もう一つ、簡単な静的シグナルとして使っているのが「モック呼び出し数とアサーション数の比率」です。モックの設定ばかりで expect が極端に少ないテストは、実装ではなくモックを検証している可能性が高い。完璧な指標ではありませんが、レビューの当たりをつけるには十分でした。
#!/usr/bin/env bash
# mock-assertion-audit.sh — テストファイルごとに mock 設定とアサーション数を概算
for f in $( find src -name '*.test.ts' ); do
mocks = $( grep -cE 'vi\.(mock|fn|spyOn)|mockReturnValue|mockResolvedValue' " $f " )
asserts = $( grep -cE 'expect\(' " $f " )
# アサーションよりモック設定が多いファイルを警告
if [ " $mocks " -gt " $asserts " ]; then
echo "⚠️ $f : mocks= $mocks asserts= $asserts (モック過多の疑い)"
fi
done
このスクリプトで引っかかったファイルを優先的にミューテーションテストにかけると、survived mutant の温床がほぼそこに集中していました。純粋なロジックまでモックで固めてしまうと、テストは実装の写し鏡にしかなりません。
CI を遅くせずに回帰だけ止める
ミューテーションテストは重い処理です。全コードに毎回かけると CI が現実的でなくなります。私は変更ファイルだけを対象にし、スコア閾値で break させる運用に落ち着きました。
name : Mutation Gate
on :
pull_request :
branches : [ main ]
jobs :
mutation :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
with :
fetch-depth : 0
- uses : actions/setup-node@v4
with :
node-version : 22
- run : npm ci
# 変更された src のテスト対象ファイルだけ抽出
- name : Collect changed sources
id : diff
run : |
CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/**/*.ts' \
| grep -v '.test.ts' | paste -sd, -)
echo "files=$CHANGED" >> "$GITHUB_OUTPUT"
- name : Run mutation on changed files
if : steps.diff.outputs.files != ''
run : npx stryker run --mutate "${{ steps.diff.outputs.files }}"
thresholds.break を下回ると Stryker は非ゼロ終了し、CI が落ちます。変更範囲に絞ることで、数百ファイルのリポジトリでも数分で回りました。最初から全体に閾値をかけると既存負債で永遠に赤になるので、新しく触った所だけ守る、という線引きが運用上は現実的でした。行カバレッジのゲートは残しつつ、その上にミューテーションのゲートを薄く重ねる二段構えにしています。
受け入れ手順を固定する
AI に書かせたテストを本番運用へ通す前に、私は次の順で受け入れ検査をしています。手順を固定しておくと、エージェントが変わっても判定がぶれません。
期待値が実装の式の再利用になっていないか(トートロジー)を目視で確認する
変更ファイルにだけ Stryker を走らせ、ミューテーションスコアと survived mutant を見る
survived mutant を名指しでエージェントに戻し、kill するテストを追加させる
この3手順を回すだけで、行カバレッジの数字に隠れていた検証の空白がほぼ埋まりました。逆に、survived mutant を放置したまま「カバレッジは足りている」と判断するのが、回帰を招く一番の落とし穴です。
どこまでやるかの線引き
個人開発で課金まわりを一人で抱えていると、割引や権限のロジックが静かにずれていても気づく人が自分しかいません。だからこそ私は、App Store の決済や Stripe の課金処理に関わる計算には高いミューテーションスコアの閾値を置き、UI の見た目や定型的な CRUD は行カバレッジで十分、と割り切るようにしています。誤検知を恐れて全部に厳しい閾値をかけると、既存の負債で CI が永遠に赤になり、回避策としてゲートごと外したくなってしまう——その誘惑に負けないための線引きでもあります。
すべての関数を高いミューテーションスコアで守る必要はありません。私は、金額計算・権限判定・課金まわりのように「間違うと静かに損害が出る」ロジックに限って閾値を高くし、UI の見た目や定型的な CRUD は行カバレッジで十分としています。AI にテストを量産させられる時代だからこそ、テストの本数ではなく「何を守っているか」に注意を向けるほうが、結局は事故を減らせました。
緑のテストは安心の記号になりがちですが、その緑が何を保証しているのかは、別の物差しで一度確かめておく価値があります。次に書くなら、まず一番こわいロジックに Stryker を一度だけ走らせてみてください。survived mutant の一覧が、テストの空白地図をそのまま見せてくれます。