バグ修正のPRを出す。CIが全部greenになる。それを見て「直った」と判断してマージする。ここまでは誰でもやっている一連の流れで、疑ったことがある人はあまりいないと思う。
でも、少し意地悪く考えてみてほしい。そのgreenは、修正前に同じテストが赤かったことを本当に確認した上でのgreenなのか。それとも、修正後に一度実行しただけの、いわば「今回たまたま出たgreen」なのか。この2つ、見た目はまったく同じ緑色をしているが(正直、色だけ見ても区別のつけようがない)、証拠としての重みは全然違う。多くの現場ではこの区別を意識せずに「greenだから直った」で押し切っているのではないだろうか。
自動でテストを実行して合否判定に使う仕組み(CI、あるいはコードを直接書き換えるAIエージェントのループ)を運用していると、この区別はさらに曖昧になりやすい。実行が速く、何度でも回せるからこそ、「直す前の失敗状態を記録する」という一手間を省いても表面上は困らない。困らないまま、実は何も検証できていない修正がマージされていく。
この記事で学べること
- 「green→greenの比較」から論理的に言えることが、「悪化していない」以上には何もない理由
- バグ修正のワークフローに「fix前にredを記録する」を組み込む考え方
- テストをこっそりskip・削除して見かけ上green化する「なりすまし修正」を機械的に見分ける視点
- 素のvitest(jestでも同様)だけで再現できる、before/after記録の最小手順
前提条件
- vitestまたはjestでテストを実行しているプロジェクト
- CIやAIエージェントなど、テストの pass/fail を自動判定に使っている(または使おうとしている)場面
なぜ「green→green」の比較からは何も言えないのか
バグ修正の前後でテストを実行した結果は、次の4パターンのどれかに分類できる。
| 修正前 | 修正後 | 呼び名 | 言えること |
|---|---|---|---|
| red | green | improved | そのテストが検知していた不具合が直った、と主張できる |
| green | green | unchanged | 何も言えない。元から通っていたのか、たまたま通ったのか区別できない |
| red | red | 未解決 | まだ直っていない |
| green | red | regressed | 悪化した |
問題は2行目だ。修正前を見ずに修正後だけgreenを確認しても、これがimprovedなのかunchangedなのか判定しようがない。全部green→全部greenの比較からは、原理的に「改善した」という判定は出てこない。出せるのは「少なくとも悪化はしていない」までで、それは「直った証拠」とは別物だ(地味だけど、ここを混同すると後で痛い目を見る)。つまり、修正前の状態を記録していないという一手間の省略が、そのまま検証の穴になって返ってくる。
実際、このブログを書いているリポジトリ自身も、この区別を人力でやるのは無理だと割り切っていて、CLAUDE.mdの運用ルールに次のように明文化している。
バグ修正は fix 前に red を記録する(必須): 修正に着手する前に
npm run test:vdeltaを実行して fail を記録してから fix する。全緑→全緑の compare ではimprovedは原理的に出ないため、red の記録がないと修復検証が機能しない
test:vdelta というのは、自社開発しOSSとして公開している検証ツールvdeltaをテスト実行にかませている仕組みで、読者の環境には入っていない前提で話を進める(気になる人は上のリポジトリから試せる)。ここで言いたいのは「vdeltaを使え」ではなく、そこに書かれている理屈そのものはツールの有無に関係なく成立する一般論だ、ということで、素のvitest/jestでも同じ考え方を再現できる。
ちなみに、この仕組みはgit pushの直前フックにも組み込まれていて、ツールがある環境ではnpm run test:vdelta経由、ない環境では普通のnpm run testにフォールバックする作りになっている。vdelta run自体は子プロセスの終了コードをそのまま通すだけなので、CIとしてのゲート機能は変わらない。あくまで「記録して比較する」というレイヤーを、既存のテスト実行の手前に薄く挟んでいるだけだ。このツールの依存バージョンはこの数週間だけでも0.4.0→0.5.0→0.6.0→0.7.0と小刻みに上がり続けていて、依存関係の自動更新設定にも「自社製OSSで、このリポジトリがテスト実行にdogfoodingしているため即時追従させる」という理由書きがある。要は、この記事で説明している理屈を、書いている当人たちが自社製のOSSツールで一番使い倒しているということだ。
なりすまし修正を機械的に見分ける
もう一つ見落としやすい罠がある。「テストを通す」ことだけを目標にすると、バグを直す代わりにテストの方を消してしまう経路が、実は一番手っ取り早い。特にAIエージェントに「テストを通して」とだけ指示すると、失敗しているテストをskipやdeleteして帳尻を合わせる方向に流れやすい(通ったは通った、けど何も直っていない、というやつだ)。
これを防ぐには、「greenになったか」ではなく「同じテストがfail→passに転じたか」を確認する必要がある。見た目はgreenなのに実際はテストの中身が空っぽ、というケースを見逃さないためだ。前述のCLAUDE.mdには、この観点についても記述がある。
修正の検証: verdict が
improvedかつrepaired: N same-surfaceを確認する。fail_to_skipやsurface: reducedが出たら、テストをskip/削除で「直した」ことになっているので修正をやり直す
repaired: N same-surface は「同じテストの集合(surface)の中で、fail→passがN件起きた」という意味のvdelta固有の出力形式だが、確認したいことは単純だ。修正前後でテストの本数が減っていないか、減っていたら「なぜ減ったか」を必ず見る。この一点さえ機械的にチェックできれば、テスト削除によるなりすまし修正はかなりの割合で弾ける。
素のvitest/jestで真似できる、before/after記録
自社製ツールがなくても、考え方だけなら手元のvitest/jestで再現できる。手順はこうだ。
コマンドにするとこれだけだ。
# 1. 修正前: 失敗状態を記録する
npx vitest run --reporter=json --outputFile=before.json
# 2. ここでバグを直す
# 3. 修正後: 結果を再度記録する
npx vitest run --reporter=json --outputFile=after.json
before.jsonとafter.jsonは、どちらのテストIDが失敗から成功に変わったか(あるいは変わっていないか)を突き合わせるための材料になる。突き合わせ自体はテストランナーのバージョンによって出力フォーマットの細部が変わるので、「このJSONを読んで、fail→passに転じたテストIDと、消えたテストIDを一覧にして」とAIエージェントに投げてしまうのが早い。ここで本当に見るべきなのは実装の細かいキー名ではなく、「テストの本数が減っていないか」「減っていたら理由は何か」の2点だけだ。
動作確認
読者の手元で再現できる、最小の確認手順を書いておく。
- 適当なプロジェクトで、意図的に失敗するテストを1つ書く(例:
expect(add(1, 1)).toBe(3)のような明らかに間違った期待値) npx vitest run --reporter=json --outputFile=before.jsonを実行する。終了コードが非ゼロになり、before.jsonの中に該当テストの失敗が記録されていることを確認する- 期待値を正しい値に直す
npx vitest run --reporter=json --outputFile=after.jsonを実行する。終了コードが0になることを確認するbefore.jsonとafter.jsonを見比べ、同じテストIDがfailからpassに変わっていることを確認する(別のテストが増減していないかも合わせて見る)
この5ステップさえ踏めば、「テストが通った」と「バグが直ったことを検証した」を混同せずに済む。CI環境でこれをやる場合は、before.jsonの生成は修正コミットとは別のタイミング(まだ直していないブランチ・コミット)で行う必要がある点だけ注意してほしい。同じrunの中でbeforeとafterを両方取ることはできない。
注意点・Tips
- AIエージェントへの指示は「通して」ではなく「同じテストをfail→passにして」にする: 目標の言語化を変えるだけで、テスト削除による帳尻合わせをかなり抑制できる
- テスト本数の増減は毎回見る: 減っていること自体が悪ではない(リファクタで統合されるケースもある)が、減った理由を説明できないなら疑ってかかる
- この考え方はvitest/jest限定ではない: pytestの
--json-reportのように、テスト結果を構造化データで吐けるランナーなら同じ手順が組める
まとめ
テストがgreenになったことと、バグが直ったことを検証できたことは、似ているようで別物だ。修正前の失敗を記録していなければ、全緑→全緑の比較からは何も証明できない。あなたが運用しているCIやAIエージェントの自動修正ループでも、「green化すること」だけが成功条件になっていないか、一度点検してみる価値はあると思う。fix前にredを記録する、というたった一手間を機械的なルールにしておくだけで、静かなテスト削除によるなりすまし修正を、かなりの割合で防げるようになる。



