AI が書いたコードをどう検証しているかと聞かれたら、少し前までの私は「テストを実行しています」と答えていたと思います。
プロジェクトには84ファイル、451個のテストがありました。Claude Code が書き、私がレビューしてマージしたものです。全部通れば、それで安心していました。
ある日、友人からミューテーションテストの話を聞きました。試しに、すでにテストがある src/lib の10ファイルを対象に実行してみることにしました。ツールが本番コードに550個の小さな変更を加え、テストがそれに気づくかを調べます。
| 対象 | 変異体の数 | Killed | Survived | No coverage |
|---|---|---|---|---|
テストがある src/lib の10ファイル |
550 | 273 | 161 | 116 |
テストが検出したのは49.6%でした。161個はテストを実行しても通り、116個は該当箇所を通るテスト自体がありませんでした。
これは「451個のテストの半分が無意味」という意味ではありません。今回の対象で生成した変異の、およそ半分を検出できなかったという結果です。それでも、テストが何を検証しているのかを見直すには十分でした。
ソースに単語があるかを調べるだけのテスト
私のプロジェクトには、こんなテストがありました。説明文だけ日本語にしています。
function source(relativePath: string): string {
return readFileSync(
fileURLToPath(new URL(`../${relativePath}`, import.meta.url)),
'utf8',
)
}
it('新しい時間クレジットと従来のユーザー基準を分離する', () => {
const codingTimeRepository = source(
'src/server/repository/coding-time-credit/index.ts',
)
expect(codingTimeRepository).toContain('userCodingTimeCreditLedger')
expect(codingTimeRepository).not.toContain('projectId')
})
ファイルを文字列として読み、特定の単語があるかを確認しています。クレジットの計算を実行したり、その結果を検証したりはしません。
こうしたテストが7ファイルに62個ありました。規約のチェックとして役立つ場合はあります。ただ、関数が間違った値を返しても、このテストは通ります。動作が正しいことを確かめるテストではありません。
ほかにも、確認したい書き方がありました。
- 要件を確認せず、実装が今返す値をそのまま期待値にする。
- 期待値を計算するために、テスト内でも本番と同じアルゴリズムを書き直す。
- mock を呼んだことだけを確認する。私のプロジェクトには
toHaveBeenCalled()が139個あり、それぞれ何を保証しているかを見直す必要がありました。 - テストファイル数やカバレッジを、重要な振る舞いにアサーションがある証拠として扱う。
AI 特有の問題というわけではありません。ただ、同じセッションで同じエージェントに実装とテストを任せると、両方が同じ思い違いを引き継ぐことがあります。
2026年の研究 All Smoke, No Alarm は、エージェントが作成した33,596件の PR に含まれる86,156件のテストファイルのパッチを調べています。構文に基づく分類では、80.2%に弱い検証シグナルしかないか、明示的な検証シグナルがありませんでした。
これは、そのデータセットで観測できるアサーションのパターンについての結果です。「AI が書くテストの80.2%は無意味」とまでは言えません。それでも、テスト数だけを数えることと、何を検証しているかを調べることの違いは伝わってきます。論文
コードを変えたときにテストが失敗するか
ミューテーションテストのツールは、本番コードを小さく変えます。< を <= に、+ を - に置き換えたり、条件を false に固定したり、戻り値を別の値にしたりします。
変更したコードの一つひとつを変異体、mutantと呼びます。その変異体に対して、関係するテストを実行します。
| 結果 | 意味 |
|---|---|
| Killed | 変異を有効にすると、少なくとも一つのテストが失敗した |
| Survived | 変更してもテストが通った |
| No coverage | 変更した箇所を通るテストがなかった |
私が主に見たのはこの三つです。実際にはタイムアウトや無効な変異など、ほかの状態もあります。Stryker の全体スコアは有効な変異体を分母にし、カバレッジがあるコードだけのスコアとは分母が異なります。Stryker の状態と指標
たとえば、次の関数です。
function isUserOldEnough(user: { age: number }) {
return user.age >= 18
}
>= 18 を > 18 に変えたとします。17歳と19歳のテストしかなければ、どちらも通ります。ちょうど18歳のテストがあれば、元の実装との違いを検出できます。
カバレッジは、比較処理を実行したことを教えてくれます。ミューテーションテストは、その比較を変えたときに、アサーションが違いを見つけるかを調べる助けになります。
11個のテストが6個の境界変更を見逃した
最初の実行では、Next.js のプロジェクトで StrykerJS と Vitest を使いました。設定は小さなものでした。
{
"testRunner": "vitest",
"plugins": ["@stryker-mutator/vitest-runner"],
"mutate": ["src/lib/**/*.ts"],
"disableTypeChecks": false,
"incremental": true,
"reporters": ["clear-text", "html"]
}
Stryker と Vitest runner をインストールし、テスト設定を読み込める状態で、次を実行しました。
npx stryker run
私のプロジェクトでは、最初の実行は1分5秒でした。
disableTypeChecks: false は、Stryker が通常挿入する @ts-nocheck を入れないための設定です。これを指定しただけで、Vitest が TypeScript の型チェックを行うわけではありません。型の検証には、別の checker やコマンドが必要です。Stryker の設定
一番印象に残ったのは、kstRelativeTime という日付表示のユーティリティでした。「たった今」「3時間前」「昨日」のような文字列を作ります。11個のテストがこの関数を実行していましたが、< を <= に置き換えた6個の変異体が、すべて生き残りました。
境界の近くを試すテストはあっても、境界そのものを試していませんでした。ちょうど48時間前ならどの表示が正しいかを、誰も確認していなかったのです。「何週間前」を返す分岐には、カバレッジ自体がありませんでした。
ほかにも気になる結果がありました。
- 同時編集の順序を扱うシーケンス番号で、
+ 1を- 1にしても通る。 - テキストのマージ処理で、
if (local === remote)をif (false)にしても通る。 - Markdown の画像パーサーでは、109個の変異体のうち検出できたのは6個だけ。
どれも、すでにテストがあるファイルです。CI も通っていました。
ソース文字列を調べるテストでは、別の問題もありました。ツールが計測用のコードを挿入すると、ファイルの文字列が変わります。そのため、動作が変わったからではなく、計測用のコードが加わったからテストが失敗することがあります。こうしたテストは、今回のミューテーション実行から外しました。
エージェントにテストを書かせるときのフィードバック
テストは、コーディングエージェントに結果を返す手段です。アサーションが弱いと、大事な動作を確認していないまま「完了」という結果を返してしまいます。
ミューテーションテストがあると、「もっとテストを書いて」と頼むより具体的な材料を渡せます。生き残った変更を示し、元の動作と変更後の動作を区別できる入力を考えてもらえます。そのうえで、その期待値が本当に要件に沿っているかを私が確認します。
Meta の Automated Compliance Hardener も、規模は違いますが、関連する方法を使っています。LLM で特定の懸念に対応する変異体を作り、既存テストが見逃す問題を検出するテストを生成します。報告された実運用では9,095個の変異体と571個のテストを生成し、Messenger と WhatsApp の test-a-thon では、エンジニアがテストの73%を受け入れました。
既存テストがすでに通っている行でも、新しいテストが別の誤りを検出できる。その点が、カバレッジだけでは見えないところだと思います。Meta の論文
ただし、プログラムがどう動くべきかは、私が決める必要があります。そこまでエージェントに任せきりにすると、同じ誤解をより細かくテストに書くだけになるかもしれません。
変更した場所の近くで実行する
実行時間は気になります。5分かかるテストスイートを1,000個の変異体に対して毎回すべて実行すると、単純計算で約83時間です。
今のツールは、その繰り返しを減らします。Stryker はテストごとのカバレッジを使って実行対象を選べますし、incremental モードでは適切な条件で前回の結果を再利用できます。毎回リポジトリ全体を対象にするのと、関係するロジックだけを対象にするのとでは、負担が大きく違います。Stryker の設定、incremental モード
Comwit と、その新規プロジェクト用テンプレートには、次の運用を入れました。
- 対象はまずロジックに絞る。ユーティリティ、サーバー処理、API、state を対象にし、この構成ではページ、ルーティング、UI コンポーネント、DB スキーマを外しました。
- 普段は変更ファイルだけに実行する。広い範囲の実行は、ときどき基準を確認するために使います。
- エージェントがテストを変更したら、変更分のミューテーションテストを実行し、生き残った変異体を確認します。
- 生き残った理由を分けて扱う。境界値ならその値を試し、未検証の分岐ならそこを通る入力を追加します。動作が同じ場合や、重要ではないログの変更は別に判断します。
私のリポジトリでは pnpm test:mutation:changed というコマンドを用意しています。これはプロジェクト独自のスクリプトで、Stryker に標準で入っているコマンドではありません。
広い範囲でも一度実行しました。ロジックの902ファイル、41,343個の変異体が対象で、私の環境では約49分でした。
| 対象 | 変異体総数 | カバレッジあり | Survived | カバレッジがある変異体に対するスコア |
|---|---|---|---|---|
| 対象ロジック全体 | 41,343 | 10,230 | 3,799 | 62.9% |
src/server/repository |
12,516 | 5,121 | 2,021 | 60.5% |
src/server/domain-jobs |
2,490 | 1,084 | 459 | 57.7% |
ここでも分母が大事です。62.9%は、生成した41,343個すべてに対する数字ではありません。カバレッジがある変異体を分母にした数字です。
生成した変異体のうち、カバレッジがあるものは約4分の1でした。さらに、その中でも3分の1以上が生き残りました。
この結果は、次に調べる場所を教えてくれました。本番に何個のバグがあるかを推定する数字としては使っていません。
スコアを見たあとに何を調べるか
生き残った変異体が、必ずしも要件の検証漏れとは限りません。コードを変更しても、外から観察できる動作が変わらない場合があります。こうした等価な変異体は、同じ正しい動作を検証するテストでは検出できません。
ミューテーションテストは、実装が意図した仕様に合っていることも証明しません。生成した変更に対して、テストが反応するかを調べます。実装とテストが同じ間違いを正しいと考えている可能性は残ります。
だから私は、ミューテーションスコアをチームの KPI にしたいとは思いません。数字を上げることが目的になれば、実行した理由を見失いそうです。
今も AI にテストを書いてもらっています。変えたのは、その次の問いです。このコードが少し間違っていたら、どのテストが気づくのか。
最初の実行では、1分ほどのミューテーションテストが、緑のチェックだけを見るよりも具体的なレビューの材料をくれました。
私は Comwit を開発しており、この運用を新規プロジェクト用テンプレートに取り入れています。この記事は、2026年10月7日に公開した韓国語の元記事をもとに、日本語向けに整理しました。
連絡先: X @meursyphus