緑のテストは何も証明しない
ガード8本のどれを潰しても全テストが緑だった。AIにテストを書かせるようになって増えた「飾りのテスト」を、わざと壊して見抜く方法。
エージェントに「異常を検知したら大きな音を立てて落ちる(fail-loud)ガード」を8本足してもらったことがあります。テストも付いていて、全部緑でした。
試しに、そのガードを1本ずつコメントアウトしてみました。8本のうち、どれを潰しても全テストが緑のままでした。「機械で強制している」と書いてあった契約は、実は何も強制されていなかったわけです。
完了報告の側の対策は「AIの「完了しました」を信じない仕組み」に書きました。
原則:壊して落ちるまでは、テストと呼ばない
テストが本当に何かを検査しているかは、検査対象をわざと壊したときに落ちるかで確かめます。いわゆる負のテスト、変異試験です。手でやるなら手順は単純です。
# 1. 検査対象を1か所だけ壊す(例:比較演算子を反転、定数を変える、ガードをreturnに)
# 2. テストを回す → 落ちることを確認
# 3. 元に戻す → 緑に戻ることを確認
git stash list # 壊す前の状態は必ず戻せるようにしておく
1回のセッションで、編集部では次の5つの型を見つけて直しました。
- 閾値が自分自身を参照している。 「定数の半分の値で試す」と書いたテストは、定数が0になるとテスト入力も0になり、何も検査しなくなります。
- 走査の順序が決まっていない。 たまたま毎回同じ順序だったので通っていました。
- 隔離がたまたま別のものを守っていた。 モジュールAの隔離のおかげで、モジュールBのバグが表に出ていませんでした。
- 指紋が粗い。 更新日時(秒単位)とサイズで変更を検知していたので、同じ秒に同じサイズで上書きされると見逃します。
テスト || exit 1が後段を殺す。 途中で抜けるので、その後ろにある汚染チェックまで到達していませんでした。
変異試験そのものが嘘をつく
Python では、変異試験までが嘘をつくことがあります。同じ文字数の定数変異(16384 → 24576)で、変異・テスト・復元が同じ1秒に収まると、.pyc のキャッシュが「変わっていない」と判断して古いバイトコードを使い回します。つまり、変異していないコードをテストしていたことになります。
export PYTHONDONTWRITEBYTECODE=1
# 変異のあとに mtime を1秒以上ずらす
touch -d '+2 seconds' target.py
測る側を先に疑う考え方は、「AIが言う原因を疑う順番」にもまとめました。
mock が「消えた実装」を隠す
6か月間見つからなかったバグがあります。リファクタのコミットが、メソッドの定義だけを消して呼び出しを残していました。本番ではシャットダウンのたびに AttributeError が出て、強制終了されていました。
テストが通っていた理由は、素の AsyncMock() が、存在しないメソッドを呼ばれるたびにその場で生やしていたからです。
from unittest.mock import AsyncMock
svc = AsyncMock(spec=RealService) # spec を付ければ、存在しない属性でエラーになる
削除を伴うリファクタでは、最後に呼び出し側を grep して締めます。削除そのものを壊さずに進める順番は「数千行をリファクタリングで消す」にまとめました。
「753/753 PASS」で33件の誤削除
未使用の翻訳キーを102件削除する監査がありました。テストは 753/753 で全部通りました。ところが敵対的レビューを1周入れたところ、102件のうち33件は実際に使われていました。動的に組み立てたキー名は、静的な grep にもテストにも引っかからなかったのです。
依存やキーが「未使用」かどうかは、import の grep だけでは決まりません。JS の import、CSS の @import、ビルド設定、package.json の scripts の4経路を見て、最後はそのツールを実際に1回動かして決着させます。
別系統のレビューの回し方は「AIコードレビューは何周で止める?」にまとめています。
テストが壊れた契約を固定する
- fixture が本番のデータの形をしていない。 ソートのバグを再現する e2e は、seed の時刻をきれいに採番順に並べていました。本番では「117件がまったく同じ時刻」だったので、バグは出ないまま緑でした。fixture が同じ timestamp、NULL、桁あふれを含んでいるかを先に見ます。
- 権限チェックが常に偽を返していた。
LIKE '%PUBLIC%'という判定は、PUBLIC が空の grantee として表示されるので、常に false でした。わざと GRANT を足して fail に変わることを確かめて、初めて回帰テストになります。 - mock した認証は、何を使っても通る。 本物の JWT(ユーザーを作成 → ログイン → Bearer で 200)を使うテストを別に書きます。
- 固定日付の fixture は時限爆弾になる。 「main は何も変わっていないのに、今日からテストが落ちる」が典型的な症状です。
- 実行時間の急増はサインになる。 スイートが 2.4秒から62秒に伸びたとき、原因はテストが検査対象そのものを差し替えていたことでした。
AIにテストを書かせるときの指示
編集部で、実装役のエージェントに必ず渡している制約です。
- 既存のテストは削除禁止、追加のみ。終了時に
wc -lが厳密に増えていることを確認させる。 - 数値の期待値は推測で書かず、実行して確かめてから書く。
- 新しく足したテストは、旧実装に戻すと落ちることを示す。
- skip した項目は「未確認」と書く。「全 pass」とまとめない。
小型のモデルに連鎖するリファクタを任せたところ、「テスト1本で足りる」と自分で解釈して、既存のテストを195行消したことがあります。テストの削除は、レビューで差分を見るまで気づきにくい変更です。
CI で回すときの費用と、止まらないジョブの止め方は「Claude Code×CIの費用と罠」に書きました。