この記事は、連載「読まない技術」の第 3 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。
今回は、AIが書いた単体テストの話です。AIは実装とテストを同時に書くので、テストが実装の写しになります。写しのテストも、読むと正しいテストに見えます。
まず一つだけ、試してみてください。
AIに書かせたテストが通っている実装を開き、実装の1行をわざと壊します。たとえば、境界値を1つずらすか、条件を反転するか、定数を書き換えます。壊したまま、テストを走らせてください(結果を見たら、壊した1行は必ず元に戻してください)。
落ちれば正常です。通ってしまったら、テストはどれも、壊した行の変化に気づかないまま合格を出しています。
あなたのプロジェクトで緑のチェックマークが付いているテストのいくつかは、この状態です。
通ってしまうのは、AIに固有の書き方のためです。AIは、実装とテストを同時に書きます。
同時に書かれたテストは、実装の写しになります。写しというのは、期待値を、動いている実装が現に返す値から取ったテストのことです。人間のチームでは、実装を書いた人がその実装に合わせてテストも書く自作自演を、レビューで防いでいました。AIでは、この自作自演がふつうの書き方です。
の手元の本番プロジェクト——typingtube という、YouTube の音楽動画でタイピング練習ができる Web サービスです——には、「通ってしまうテスト」を見つけて直した形跡が、テストのコメントに26か所残っています。「学習帳(単語帳でタイピング練習する機能)の首都データを別の都市に書き換えても合格のままだった」「2 つの検証のうち片方を壊しても、もう片方が隠して気づけなかった」——どれも、実装を壊してテストを走らせた結果から見つけたものです。
AIに実装とテストを書かせると、テストは全部通ります。ところが、その中には、実装の1行が変わっても合格を出し続けるテストが混じっています。そうしたテストを見つけるのに、あなたなら次のどちらを選びますか。
- テストの本文を読んで、何を確かめているかを判断する
- 実装の1行を壊して、テストを走らせる
答えは、実装を壊して走らせるほうです。合格を出し続けるテストの期待値は、AIが同じコンテキストで書いた実装から取ったものです。こうしたテストが確かめるのは、AIがアサーションに貼った値だけで、実装が返すほかの値がどう変わっても、テストは合格します。それでも、あなたがテストの本文を読むと、仕様を守っているテストに見えます。アサーションに貼られた値からは、その値を仕様から決めたのか、実装が返した値を貼ったのかを、見分けることができないからです。あなたが実装の1行を壊してテストを走らせると、返ってくるのは、落ちたか通ったかだけです。この結果は、AIが書いた文章を通らずに届きます。通ったままなら、そのテストは、壊した行の変化を確かめていません。
理解したことは一文にできます。
AIは実装とテストを同時に書くから、テストが実装の写しになる。写しのテストが何を確かめていないかは、壊してみたときに初めて分かる。
CC BY 4.0
なぜ、写しになるのか
テストの期待値には、出どころが二つあります。仕様——何を正しいとするか——から決めるか、実装が現に返す値から決めるかです。前者の期待値は実装とは別の根拠を持つので、実装が間違っていれば、テストは落ちます。後者の期待値の根拠は、実装と同じです。実装が間違っていても、期待値のほうがその間違いに合わせてあるので、テストは通ります。
写しは後者です。写しのテストにも、テスト観点はそろっています。AIは境界値や異常系を、項目としては並べます。違うのは期待値の出どころです。期待値の一つ一つに、実装が現に返した値が入っています。AIはその値をそのままアサーションに貼り、実装と同じ計算をモックの中で繰り返します。このテストは、見た目には仕様を守っているテストです。ところが、次にAIが実装を書き換えるときは、テストのほうも一緒に書き換えます。
AIが実装の返す値を期待値に使うのは、一つのリクエストの中で実装を書き、その続きでテストを書くからです。テストを書く時点で、同じコンテキストに実装が載っています。
期待値を実装の実行結果から作るのは、AIより前からある、テストの自動生成の弱点です。Konstantinou ほかは 2024 年に、Randoop や EvoSuite のような従来のテスト生成ツールと同じく、LLM が生成するテストも、期待される振る舞いの代わりに、プログラムが実際にする振る舞いを期待値に写しやすいと報告しました。Konstantinou ほかは、この実験で、テストの対象になるメソッドの実装を、どの条件でもプロンプトに入れています。LLMが期待値の正誤を判定し損ねたときに書いた理由は、実装から導ける結論に沿っていました。Konstantinou ほかは、ここから、LLMは実際のコードの実行をたどって期待値を見つけている、と結論しています。
写しのテストでは、実装を作る側と、テストで確かめる側も、同じAIです。Claude Code のドキュメントは、AIに検証の手段を与える方法を並べた最後に、仕事をした agent が採点する agent にならないようにと書いています。
公式のプロンプト指針の、テストについての節は、Claude はテストを通すことに集中しすぎて、より一般的な解を犠牲にすることがあると書いています。そこで勧められているのは、プロンプトに「テストは正しさを検証するためにあり、解を定義するためではない」と書くことです。AIにとってテストを通すことが目的になれば、実装が返す値をそのままアサーションに貼るのが、いちばん手間の少ない書き方になります。研究の側でこれに当たる語は specification gaming で、目的の字面の仕様を満たすが、意図した結果を達成しない振る舞いと定義されています。写しのテストが満たしているのは、「テストが通る」という字面の仕様です。
写しのテストには、欠けているものが二つあります。一つは、期待値の根拠です。書いた時点の実装に誤りがあれば、テストはその誤りを、正しい値として固定します。もう一つは、確かめる範囲です。写しのテストが確かめるのは、AIがアサーションに貼った値だけです。実装が返すほかの値や、実装が書き換えるデータは、アサーションに貼られていなければ、どう変わってもテストは通ります。冒頭で壊した行が通ってしまったのは、後者です。
では、写しのテストが確かめていないところは、どうすれば見つかるのか。Huang ほかは 2023 年に、LLM は外部のフィードバックが無いと自分の誤りを直すことができず、ときに直そうとしてかえって悪化させると報告しました。実装を1行壊してテストを走らせることは、その外部のフィードバックを作る操作です。テストの実行が返すのは、落ちたか通ったかのどちらかです。この結果は、AIが書く文章を通らずに届きます。
ミューテーションテストは、何を測るのか
冒頭で実装の1行を壊してテストを走らせた操作は、ミューテーションテスト(変異テスト、mutation testing)という手法を、手で1回だけ行ったものです。ミューテーションテストは、テストが実装の誤りに気づけるかを測る手法です。
ミューテーションテストでは、実装を1か所だけ小さく書き換えたものを、変異(ミュータント、mutant)と呼びます。書き換え方は決まっていて、比較の < を <= にする、+ を - にする、条件を反転する、定数を変える、といったものです。この書き換え方を、変異演算子(mutation operator)と呼びます。
手順は三つで、ふつうはミューテーションテストのツールが自動で回す作業です。まず、ツールはテストを元の実装で走らせ、全部通ることを確かめます。次に、変異を1つずつ入れて、そのたびにテストを走らせます。どれかのテストが落ちた変異は「殺された(killed)」変異、全部のテストが通った変異は「生き残った(survived)」変異です。最後に、ツールは殺された変異の数を、変異の総数で割ります。この値が変異スコア(mutation score)です。生き残った変異の場所は、実装がそう変わっても、テストが気づかない場所です。
変異の中には、書き換えても振る舞いが変わらないものがあります。たとえば、i が1ずつ増えるループの終了条件 i < n を i != n に変えても、ループの回る回数は同じです。こうした変異を等価変異(equivalent mutant)と呼び、どんなテストを書いても殺すことができません。変異スコアの分母は、等価変異を除いた変異の数です。ある変異が等価かどうかを、プログラムですべて見分けることはできません。二つのプログラムが同じ振る舞いをするかは、一般には決定できない問題だからです(Jia と Harman のサーベイ)。そのため、生き残った変異は、人かAIが1件ずつ読んで、等価変異かどうかを確かめます。
カバレッジが数えるのは、テストが実行した行です。アサーションが一つも無いテストでも、実行した行はカバレッジに入ります。ミューテーションテストが測るのは、実行した結果をテストが確かめたかです。Just ほかは 2014 年に、テストが変異を検出できるかと、実際の欠陥を検出できるかには、カバレッジとは独立した相関があると報告しました。
ミューテーションテストが壊せるのは、実装に書かれている行だけです。実装にもテストにも書かれていない処理には、壊す行がありません。テストの抜け漏れには、写しとは別の原因もあります。Claude Code は、コンテキストが上限に近づくと、古いツールの出力から消し、必要なら会話を要約します。依頼と主要なコードの断片は残りますが、会話の前半で渡した詳しい指示は、失われることがあります。前半で渡したテストの観点が失われれば、その観点は実装にもテストにも現れません。壊す行が無いので、AIが変異を入れても、その抜けは見つかりません。
「ミューテーションテストは遅くて割に合わない」への答え
この手法には「有効だが、全変異を機械生成して回すのは遅く、大半のプロジェクトで割に合わない」という評価があります。全部の行に全部の変異演算子を当てると、変異の数だけテストを走らせることになるので、この評価は正しいです。
全変異を回すのは、テストの一式の強さを、変異スコアという1つの数で表すためです。数が要らなければ、変異は絞ることができます。Petrović と Ivanković は 2018 年に、Google のコードレビューでの運用を報告しました。変更された行のうち、テストが実行していて意味のある行だけに、1行に1つまで変異を入れ、生き残った変異をレビューの指摘として出しています。
のやり方は、これをさらに絞ったものです。が知りたいのは、変異スコアより、生き残った変異そのもの、つまりテストが実装のどこを確かめていないかです。は、変異を入れる箇所をAI自身に数か所だけ選ばせ、AIに変異を入れさせます。実装のどこが壊れたら困るかを一番知っているのは、実装を書いたAI自身だからです。Foster ほかは 2025 年に、Meta で LLM に、気にかけている問題に絞った少数の変異を作らせ、その変異を殺すテストを生成させる運用を報告しています。選ぶのはAIでも、落ちたか通ったかは、テストを走らせた結果で決まります。
仕組み: 方針ファイルを1枚置く
置くのは、次の方針ファイル1枚だけです(手元の本番プロジェクトで運用しているものの簡約版)。
# 変異注入の方針(テストの点検を頼まれたら読む)
目的: AI が書いたテストが、実装のどこを確かめていないかを調べる。
変異 = 実装を 1 か所だけ意図的に壊すこと。壊しても全部のテストが通るなら、その変化に気づくテストが無い。
当てる回 = 実装かテストを変えた回だけ。データや設定を変えただけの回には当てない(テストの確かめ方は前の回から変わっていない)。
1. 変異は「テストが落ちるはずの行」を選ぶ。境界値・条件分岐・計算式を優先する
(ログの出力のようにテストが確かめなくてよい行や、振る舞いが変わらない書き換えは、
通ってもテストの抜け穴ではない)
2. 1 か所壊す → テストを走らせる → 結果を記録 → 必ず元に戻す(git diff が
空に戻ったことまで確認する)。これを数か所
3. 最初の 1 件は「確実に落ちる変異」にする。全部通ったら、まず測定器の側を疑う
4. 落ちた変異が示すのは、その 1 か所の変化にテストが気づくことだけ。
詳細を報告するのは「通ってしまった変異」だけ(落ちた側は件数のみ)
5. 通ってしまった変異には、その変化に気づくテストを足す。期待値は、実装が返す値を貼らずに、仕様から決める
使うときは、AIに「この方針を読んで、今日書いたテストに変異を入れてみて」と頼むだけです。
変異をどこに何か所入れるかは、AIが判断します。方針の 1 と 3 が扱っているのは、AIが実際にした失敗です。テストが落ちないのが設計どおりの行に変異を入れて、「テストの不備だ」とAIが報告したこともあれば、何を壊しても通ったままで、テスト自体が走っていなかったこともあります。
方針 2 の「必ず元に戻す」は、方針の中でいちばん大事な1行です。変異を入れているあいだ、作業ツリーにあるのは、わざと壊した実装です。AIが実装を戻し切る前に、コミットや、並列実行しているほかのセッションの作業が走ると、わざと入れた欠陥が、そのままコミットやほかのセッションの作業に入ります。
「5 か所壊して 5 か所落ちました」という報告は、テストが写しでも返ってきます。写しのテストでも、変異でアサーションに貼った値が変われば落ちるからです。意味があるのは、通ってしまった変異だけです。1件見つかれば、この方針ファイルを置いた手間は、それで元を取ったことになります。その5という数を数えて報告しているのも、変異を入れたのと同じAIです。
AIは、実装の全体から抜き取った数か所にだけ変異を入れます。全部落ちたときに分かるのは、その数か所の変化にテストが気づくことだけです。そのとき、AIの報告は「5 か所すべてが落ちました」で終わります。あなたがこの1行を品質の証拠として受け取ると、変異を入れなかった箇所にテストが確かめていないところが残っていても、安心してしまいます。
CC BY 4.0 はここまで
読まなくなったもの
AIが書いたテストの本文です。
は、「ちゃんとテストできているか」をテストの本文を読んで判断する作業を、実装を壊して確かめる作業に置き換えました。テストの本文を読むと、写しのテストも正しく見えます。変異を入れたときに返ってくるのは、落ちたか通ったかだけです。
CC BY 4.0
変異を入れるのは、実装かテストを変えた回だけです
毎回AIに「変異を入れて」と頼んでいると、AIは、テストが読むデータや設定だけを書き換えた回にも、変異を入れはじめます。
その回に変異を入れても、分かることは前に測ったときと変わりません。変異で分かるのは、テストが実装のどこを確かめているかで、実装もテストも前の回のままだからです。方針ファイルに「当てる回」の1行があると、AIはこうした回を、変異を入れる対象から外します。
注意: 変異は、期待値が正しいかを見ない
ミューテーションテストは、元の実装を正解として扱います。手順の最初に、テストが元の実装で全部通ることを確かめ、元の実装が間違っていれば、変異を入れる前に直します(Jia と Harman のサーベイ)。変異で測るのは、実装が元からずれたときにテストが気づくかで、元の実装が仕様どおりかではありません。
写しのテストの期待値は、書いた時点の実装が返した値です。その実装に誤りがあっても、テストは元の実装で通り、変異を入れれば落ちます。変異スコアは高く出て、誤りは期待値の中に残ります。Konstantinou ほかが報告した、LLM が実際の振る舞いを期待値に写す弱点は、変異では見つかりません。期待値の誤りを見つけるには、期待値を、実装を見ずに仕様から決め直すことです。方針の 5 がそれをしているのは、通ってしまった変異のところだけです。
検証手順: 変異を入れて、テストが落ちるかを確かめる
前提
- この記事の方針ファイルを1枚置き終えていること
- AIが書いたテストが1ファイル以上あり、そのテストがいまは全部通っていること
- 作業中の変更が無い状態(
git statusが空)から始めること。後始末で元に戻す変異と、あなたの編集が混ざらないようにするためです
所要時間: 15分
手順
- AIに、既存のテスト1ファイルが守っている実装へ、変異を入れるよう頼みます——「この方針を読んで、
<テストファイル名>が守っている実装に変異を入れてみて」。どこに何か所入れるかはAIが決めます - あなたが、AIの報告を3つに分けて読みます——落ちた変異の件数、通ってしまった変異の件数、そして通ってしまった変異の中身(どこを壊したか)
合格条件(すべて満たすこと)
- 落ちた変異が1件以上ある
- 通ってしまった変異が0件である
合格しなかったとき
- 通ってしまった変異が1件以上あった —— それが収穫です。先に、その変異で振る舞いが変わるか(等価変異でないか)を確かめます。変わるなら、その変化に気づくテストがありません。AIに、期待値を仕様から決めて、その変化に気づくテストを足させます
- 落ちた変異が0件(全部通った) —— 変異の質を疑う前に、テストが実行されているかを疑ってください。先に確かめるのは、ファイル名の指定違いと、そのテストが実行の対象から外れていることです
後始末
- 変異を入れた実装を、全部元に戻します。
git diffが空になることを目で確かめます - この後始末が、手順の中でいちばん大事です。戻し切る前にコミットやほかのセッションの作業が走ると、わざと入れた欠陥が、そのままコミットやほかのセッションの作業に入ります
CC BY 4.0 はここまで
次回は「スキルを使わない技術」。は、AI にスキルを登録するのをやめました。 それでもAIは、多数の“スキル”を使いこなしています。
連載「読まない技術」
- ← 前回: 第 2 回 メモリを読まない技術
- → 次回: 第 4 回 スキルを使わない技術
- 全回の一覧: 序論 AIの出力を、もうほとんど読んでいない