この記事は、連載「読まない技術」の第 3 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。

今回は、AI が書いた単体テストの話です。AI は実装とテストを同時に書くので、テストが実装の写しになります。写しかどうかは、読んでも分かりません(読み物としては正しく見えるからです)。通っているテストが本当に何かを守っているのかを、テストを読まずに確かめます。

まず一つだけ、試してみてください。

AI に書かせたテストが通っている実装を開き、実装の 1 行をわざと壊します。境界値を 1 つずらす、条件を反転する、定数を書き換える。それでテストを走らせてください(見終わったら、壊した 1 行は必ず元に戻します)。

落ちれば正常です。通ってしまったら、そのテストは最初から何も見ていません。

プロジェクトの緑のチェックマークのいくつかは、この状態です。

通ってしまう理由は、AI に固有の構造です。AI は実装とテストを同時に書きます。

同時に書かれたテストは、実装の写しになります。期待値が、仕様からではなく、動いている実装が現に返す値から取られているテストのことです。人間のチームがレビューで防いでいた自作自演が、AI では標準の生産形態になります。

私の手元の本番プロジェクト——typingtube という、YouTube の音楽動画でタイピング練習ができる Web サービスです——には、この方法で「通ってしまうテスト」を見つけて直した形跡が、テストのコメントに 26 か所残っています。「学習帳(単語帳でタイピング練習する機能)の首都データを別の都市に書き換えても合格のままだった」「2 つの検証のうち片方を壊しても、もう片方が隠して気づけなかった」——どれも読んで見つけたものではありません。

理解したことは一文にできます。

AI は実装とテストを同時に書くから、テストが実装の写しになる。写しかどうかは、壊してみたときにしか分からない。

CC BY 4.0

なぜ、写しになるのか

テストの期待値には、出どころが二つあります。仕様——何を正しいとするか——から決めるか、実装が現に返す値から決めるかです。前者は実装とは別の根拠を持つので、実装が間違っていれば落ちます。後者は実装と同じ根拠しか持ちません。実装が間違っていても、期待値のほうがその間違いに合わせてあるので、通ります。

写しは後者です。ここで落ちているのは、テストの観点ではありません。AI は境界値や異常系を、項目としては並べます。抜けているのは期待値の出どころだけで、その一つ一つに、実装が現に返した値が入っています——出力をアサーションに貼り、実装と同じ計算をモックの中で繰り返す。守っているように見えて、固定しているものが一つもありません。次に実装を書き換えたとき、AI はテストのほうも一緒に書き換えます。

AI がそこへ寄るのは、一つのリクエストの中で実装を書き、その続きでテストを書くからです。テストを書く時点で、同じコンテキストに実装が載っています。期待値の材料として一番近いところにあるのは、いま自分が書いた実装です。仕様のほうは、書かれていないか、書かれていても会話の何十ターンか前にあります。Liu ほかは 2023 年に、長い入力の先頭と末尾に置いた情報は取り出せるが、その間に置いたものの精度は有意に落ちると報告しました。実装は末尾にあり、仕様はその間に埋もれています。

作る側と、確かめる側が同じでもあります。Claude Code のドキュメントは、AI に検証の手段を与える方法を並べた最後に、仕事をした agent が採点する agent にならないようにと書いています。

公式のプロンプト指針には、テストについての節が一つあります。Claude はテストを通すことに集中しすぎて、より一般的な解を犠牲にすることがある。そこで勧められている書き方は「テストは正しさを検証するためにあり、解を定義するためではない」です。テストを通すことが目的になれば、実装の出力をそのままアサーションに貼るのが最短の道になります。研究の側でこれに当たる語は specification gaming で、目的の字面の仕様を満たすが、意図した結果を達成しない振る舞いと定義されています。写しのテストは、通るという字面を満たしています。

では、何が写しを見つけるのか。テスト観点の一覧を先に渡しても、写しは防げません。観点は落ちていないからです。Huang ほかは 2023 年に、LLM は外部のフィードバックが無いと自分の誤りを直せず、ときに直そうとしてかえって悪化させると報告しました。実装を 1 行壊してテストを走らせることは、その外部のフィードバックを作る操作です。落ちるか、通るか。返ってくるのはそのどちらかで、AI の文章を経由しません。

「ミューテーションテストは遅くて割に合わない」への答え

いまやったことにはミューテーションテスト(変異テスト、mutation testing)という名前があります。この手法には「有効だが、全変異を機械生成して回すのは遅く、大半のプロジェクトで割に合わない」という評価があります。それは正しくて、ここでは前提が二つ違います。

対象が違う——人間が書いたテストの網羅度測定ではなく、AI が書いたテストの自作自演検出です。やり方が違う——専用ツールで全変異を生成するのではなく、AI 自身に数か所だけ選ばせて注入させます。どこが壊れたら困るかを一番知っているのは、実装を書いた AI 自身だからです。選ぶのは AI でも、落ちたか通ったかを決めるのはテストの返り値のほうです。

仕組み: 方針ファイルを 1 枚置く

置くのはこれだけです(手元の運用からの簡約版)。

# 変異注入の方針(テストの点検を頼まれたら読む)

目的: AI が書いたテストが「実装の写し」になっていないか調べる。
変異 = 実装を 1 か所だけ意図的に壊すこと。壊しても通るテストは、その箇所を見ていない。
当てる回 = 実装かテストを変えた回だけ。データや設定を変えただけの回には当てない(結果は前の回と同じになる)。

1. 変異は「テストが落ちるはずの行」を選ぶ。境界値・条件分岐・計算式を優先する
   (当たらないはずの行を壊して通っても、それはテストの抜け穴ではない)
2. 1 か所壊す → テストを走らせる → 結果を記録 → 必ず元に戻す(git diff が
   空に戻ったことまで確認する)。これを数か所
3. 最初の 1 件は「確実に落ちる変異」にする。全部通ったら、まず測定器の側を疑う
4. 落ちた変異は何も保証しない。詳細を報告するのは「通ってしまった変異」だけ
   (落ちた側は件数のみ)
5. 通ってしまったテストは、実装の写しではなく仕様から書き直す

使い方は「この方針を読んで、今日書いたテストに変異を入れてみて」と頼むだけ。

どこに何か所入れるかの判断は AI がします。方針の 1 と 3 は、AI が実際に踏んだ落とし穴です。設計上当たらないのが正しい行を壊して「テストの不備だ」と AI が騒いだこともあれば、何を壊しても合格のままでテスト自体が走っていなかったこともあります。

方針 2 の「必ず元に戻す」は、この中でいちばん外せない 1 行です。変異の最中は、わざと壊した実装がその場に生きています。戻し切る前にコミットや並列実行中のセッションの作業が走ると、意図した欠陥がそのまま先へ進みます。

「5 か所壊して 5 か所落ちました」は成果ではありません。落ちた変異は何も保証しない——写しのテストでも、写した範囲の変異なら落ちます。意味があるのは通ってしまった変異だけで、1 件出たらこの方針ファイルは元を取っています。その 5 という数を数えているのも、変異を入れた AI です。

CC BY 4.0 はここまで

読まなくなったもの

AI が書いたテストの本文です。

私は、「ちゃんとテストできているか」をテストコードを読んで判断する作業を、壊して確かめる作業に置き換えました。読む目は写しに騙されますが、変異は騙されません。

CC BY 4.0

当てるのは、実装かテストを変えた回だけです

毎回「変異を入れて」と頼んでいると、当てなくていい回にも当たりはじめます。実装もテストも触っていない回——テストが読むデータや設定を書き換えただけの回——にも入ってきます。

そこで落ちても収穫はありません。実装もテストも前の回から動いていない以上、結果は前に測ったときと同じだからです。方針ファイルの「当てる回」の 1 行が、これを止めます。

注意: 変異が答えるのは、写しかどうかだけ

壊した行をテストが見ていなければ、テストは通ります。見ていれば、テストは落ちます。写しかどうかは、そこで決まります。読んで判断する余地がありません。写しが原因の見落としは、変異を当てた箇所については、これで全部出てきます。

出てこないのは、テストがそもそも無いところです。実装に書かれていない処理には、壊す行がありません。テストの抜け漏れは、写しとは別の原因で起きます。コンテキストに載る量は決まっているので、作業の途中で挙がった観点は、後から入ってくるものに押し出されます。押し出された観点は、実装にもテストにも現れません。壊す対象が無いので、変異注入はそれを見つけません。写しに効くことと、テストの網羅を保証することは、別のことです。

そのうえで、数か所の変異は抜き取りです。通った変異が見つかればそのテストは確実に直せますが、全部落ちても「品質は万全」にはなりません。AI の報告は「5 か所すべてが落ちました」で終わります。落ちた変異は、実装を写しただけのテストでも落ちるからです。この 1 行を品質の証拠として受け取った時点で、この方針ファイルは偽の安心に変わります。

検証手順: 測定器のほうから確かめる

前提

  • この回の方針ファイルを 1 枚置き終えていること
  • AI が書いたテストが 1 ファイル以上あり、そのテストが今は全部通っていること
  • 作業中の変更が無い状態(git status が空)から始めること。後始末で戻すものと、あなたの編集を混ぜないためです

所要時間: 15 分

手順

  1. AI に、既存のテスト 1 ファイルへの変異注入を頼みます——「この方針を読んで、<テストファイル名> が守っている実装に変異を入れてみて」。どこに何か所入れるかは AI が決めます
  2. あなたが、報告を 3 つに分けて読みます——落ちた変異の件数、通ってしまった変異の件数、そして通ってしまったものの中身(どこを壊したか)

合格条件(すべて満たすこと)

  • 落ちた変異が 1 件以上ある
  • 通ってしまった変異が 0 件である

合格しなかったとき

  • 通ってしまった変異が 1 件以上あった —— それが収穫です。そのテストは、壊した箇所を見ていません。AI に、そのテストを実装からではなく仕様から書き直させます
  • 落ちた変異が 0 件(全部通った) —— 変異の質を疑う前に、テストが実行されているかを疑ってください。ファイル名の指定違いや、そのテストが実行の対象から外れている可能性が先です

後始末

  • 変異を入れた実装を、全部元に戻します。git diff が空になることを目で確かめます
  • ここが最重要です。戻し切る前にコミットや他のセッションの作業が走ると、わざと入れた欠陥がそのまま先へ進みます

CC BY 4.0 はここまで

次回は「スキルを使わない技術」。私は、AI にスキルを登録するのをやめました。それでも AI は、多数の“スキル”を使いこなしています。

連載「読まない技術」