メインコンテンツへスキップ
見出し画像

AIが書いたテストに、実装の欠陥20個をぶつけた


    AIにテストを書かせると、たいてい全部通る。緑になる。それで安心していいのか、ずっと引っかかっていた。

    テストが通ることは「バグがない」ことの証明ではなく、「そのテストでは見つからなかった」という事実でしかない。ならば逆から確かめればいい。実装のほうにわざとバグを入れて、テストが気づくかどうかを見る。

    自分のリポジトリで実際にやってみた。結果を先に置きます。

    ・AIが書いたテスト 34件は、実装に入れた 20個のコード変異のうち 12個しか検知できなかった
    ・ただし1個は挙動が変わらない変異だった。実質は 19個中7個を見逃した(12/19)
    ・見逃した7個は すべて「しきい値の±1ずらし」。それ以外の変異は8個中8個を捕まえていた
    ・境界値テストを 11件足すと、同じ19個はすべて検知できるようになった

    つまり穴はランダムではなく、一箇所に偏っていた。

    実験台は、毎日使っている168行のバリデータ


    対象は自作の tools/note-preflight/check.mjs。note記事を公開前に機械検査するスクリプトで、168行・13ルール。太字の数、見出しの構造、出典の本数、禁止表現などをチェックして、引っかかれば終了コード1で止める。

    飾りではなく毎回通している実運用のコードです。だから「テストが効いていない」なら自分が困る。

    中身はほぼ全部がしきい値の判定でできている(メッセージ文字列は長いので短くしています)。

    js
    if (boldPairs === 0) F('太字0対=核数字の太字が消えている');
    else if (boldPairs > 30) F(太字${boldPairs}対=過多);

    if (h2s.length < 4) W(H2見出しが${h2s.length}個=少ない);
    if (dois < 3) F(出典が${dois}件=根拠不足);
    ```

    この「数字と比較演算子の集合体」という性質が、後で効いてきます。

    AIに書かせた34件は、初回から全部通った


    まず普通にテストを書かせた。「このファイルのテストを書いて」程度の指示で出てきたのが34件。CLIなので子プロセスで起動し、終了コードと出力JSONを検証する形にした。

    13ルールをひと通り網羅していて、正常系、異常系、CLIの終了コードまで見ている。走らせたら pass 34 / fail 0。一度も赤くならなかった。

    普段ならここで満足して終わります。

    ここで手を止めて、テストファイルをSHA-256で凍結した。

    
    bfb887e1b4e02f61bf106444771663fd449c14fe8c0f5933d7071b79c234a7f3
    ```

    理由は、欠陥を知ってからテストを書き足せば、テストは不自然に強くなってしまうからです。ハッシュを取っておけば、あとから自分がテストに手を入れていないことを確認できる。

    ただし正直に書くと、この凍結で防げる範囲は限られています。欠陥を設計したのも同じClaudeのセッションで、テストの中身を知らない第三者が変異を選んだわけではない。通り抜けやすい変更が無意識に選ばれた可能性は残ります。 凍結はテストの後付け修正を防ぎますが、欠陥選択の独立性までは保証しません。

    凍結してから、20個の欠陥を注入した


    画像


    やったことはミューテーションテストと呼ばれる古典的な手法です。実装に小さな変異を注入し、テストが落ちれば killed、通ってしまえば survived と数える。Strykerの定義では、killed は「そのミュータントが有効な間に少なくとも1つのテストが落ちたこと」を指します。

    ここも正確に書きます。20個は、ツールが全適用点から自動生成したものではありません。標準的な演算子を参考にしながら、どの行にどの変換を当てるかを自分で選んでJSONに並べたものです。候補集合や抽出規則を事前登録してはいないので、以下はこの選んだ20個に対する成績だと読んでください。

    ・演算子(呼び名)|やること|例
    ・boundary-shift|しきい値の定数を1ずらす|boldPairs > 30 を > 31 に
    ・constant-replacement|定数を別の値に|h1 !== 1 を h1 !== 0 に
    ・logical-operator|論理演算子を反転|`A|B を A && B` に
    ・statement-deletion|判定を1行まるごと削除|if (tanpaq > 0) F(…) を消す
    ・return-value|戻り値を変える|終了コード1を2に

    表の名前は自分で付けたラベルです。とくに boundary-shift は、PITでいう CONDITIONALS_BOUNDARY(< を <= に替える)ではなく、インライン定数を1増減させる CRCR に近い。PITでは既定で無効の実験的ミューテータです。

    あとは1個ずつ注入して node --test を回し、終了コードで判定するだけ。Node.jsの公式ドキュメントに「テストが1つでも落ちればプロセスの終了コードは1になる」と明記されているので、ハーネスは数十行で済みます。

    js
    writeFileSync('./check.mjs', orig.replace(mt.from, mt.to));
    const r = spawnSync(process.execPath, ['--test'], { encoding: 'utf8' });
    const killed = r.status !== 0;
    ```

    この書き方だと起動失敗やクラッシュもkilledに数えてしまうので、結果JSONも確認しました。killedとした12件はいずれも実際に1件以上のテストが落ちています。

    生き残った8個は、ほぼ全部が「境界」だった


    画像


    結果は 12 killed / 8 survived。34件のテストが全部通る状態で、注入した変異の4割が素通りした。

    生き残った顔ぶれを見て、ぼくは少し背筋が寒くなった。

    ・ID|ルール|変更
    ・M02|太字の上限|boldPairs > 30 を > 31
    ・M04|単一アスタリスク|singleAsterisk > 0 を > 1
    ・M10|H2見出しの下限|h2s.length < 4 を < 3
    ・M11|出典URLの下限|urls.length < 3 を < 2
    ・M12|出典DOIの下限|dois < 3 を < 2
    ・M13|壁テキスト判定|t.length > 200 を > 201
    ・M14|文字数の下限|chars < 2500 を < 2499
    ・M15|pass判定|fails.length === 0 を <= 0

    種類で割ると、偏りがはっきりする。

    ・変異の種類|殺せた数|割合
    ・boundary-shift(しきい値の±1)|4 / 11|36.4%
    ・それ以外(挙動が変わるもの)|8 / 8|100%

    同じテストスイートなのに、変異の種類で結果が割れた。この168行では、行を消せば気づいたが、数字を1ずらすと気づかなかった。

    理由は書かれたテストを読めばすぐわかった。たとえば出典の下限は3件なのに、テストは「1件ならFAIL」「3件ならPASS」しか見ていない。2件のときどうなるかを誰も書いていない。だから < 3 が < 2 に化けても、どのテストも痛くない。

    Claudeは「ルールが動くこと」を確認するテストを書いた。「ルールがどこで判定が切り替わるか」を固定するテストは書かなかった。ここは責める気になれなくて、ぼく自身が同じものを手で書いても、たぶん同じ34件になったと思う。

    唯一どうやっても殺せない1個がある


    上の表のM15だけは性質が違います。fails.length === 0 を <= 0 に変える変異ですが、配列の length は0未満にならないので、この2つはあらゆる入力で同じ挙動をします。

    これは等価ミュータント(equivalent mutant)と呼ばれるもので、テストの穴ではなくノイズです。Strykerの公式ドキュメントは「これを見つけて無視する確実な方法はない」と書いています。理論の側でも、ある変異が等価かどうかの自動判定は決定不能とされています(Inozemtseva と Holmes の論文の脚注)。

    実測値を出すなら、ここを黙っておくのはフェアではない。60.0%は等価ミュータントを含んだ生の値で、M15を除けば12/19=63.2%になります。

    境界値テストを11件足したら、95%になった


    穴の正体が「境界」だとわかったので、対策は素直です。しきい値ごとに、判定が切り替わる内側と外側を1件ずつ固定する。7つのしきい値に対して、合計11件になりました。

    元の34件は凍結したまま、追加ファイルに書いています。

    js
    // 境界の内側と外側を、1件ずつ固定する
    test('ちょうど30対は FAIL にしない', () => { ... });
    test('31対で FAIL になる',           () => { ... });
    ```

    同じ20個の変異をもう一度ぶつけた結果がこれです。

    ・指標|前|後
    ・テスト件数|34|45(+11)
    ・killed|12|19
    ・survived|8|1
    ・ミューテーションスコア|60.0%|95.0%

    残った1個は先ほどの等価ミュータントなので、挙動が変わる19個は全部殺せた状態です。

    ただしここも過大に読まないでください。追加した11件は、生き残った変異を見てから設計しています。これは既知の穴を塞げたことを示す結果であって、未知の欠陥に対する検出力が上がった証拠ではありません。

    研究はどう言っているか(そして反証もある)


    「カバレッジが高くても安心できない」という話には、長い蓄積があります。

    Inozemtseva と Holmes は5つの大規模Javaプロジェクトで 31,000のテストスイートを生成して検証し、テストスイートのサイズを統制すると、カバレッジと有効性の相関は低〜中程度まで落ちると報告しました(ICSE 2014)。結論は「高いカバレッジはテストスイートが有効であることを示さない」です。

    Googleはこれを実務に載せていて、20億行のリポジトリで6,000人のエンジニアのコードレビューにミューテーション結果を出しています(ICSE-SEIP 2018)。面白いのは、出す情報を絞る工夫で開発者の「役に立った」評価が20%から80%へ改善したという報告です。全部見せると邪魔になる。

    AI生成テストに絞った測定もあります。あるIEEE TSE採録の研究では、LLMが生成したテストで行・分岐カバレッジが100%なのにミューテーションスコアは4%という例が報告されています。ただしこれは204サブジェクト中の1事例で、平均ではありません。

    自分に不利な話も書いておきます。ミューテーションスコアが上がることと、実際のバグを多く捕まえられることはイコールではありません。

    Papadakis らは実バグ(Cの68件・Javaの231件)で検証し、テストスイートのサイズを統制すると両者の相関はおおむね0.05から0.20まで弱まると報告しています(ICSE 2018)。ぼくが上げたのは「仕込んだ変異の検知率」であって、「バグが減った」ではない。

    さらに、この手の話をそのまま信じるのも早い。ISSTA 2026採録の再現研究は、LLM生成テストにおいてはテストスイートのサイズが支配的な交絡因子だという証拠は乏しいとし、カバレッジやミューテーションは「条件付きでなら有用」と結論しています。

    つまり「カバレッジは無意味、ミューテーションこそ正義」という単純な図式では、もう最新の到達点に追いつきません。ぼくの実験も、その図式の証明にはなりません。

    この実験でわかっていないこと


    数字が一人歩きすると困るので、限界をはっきり書いておきます。

    環境は macOS と Node.js v24.15.0、実施は2026年8月31日。テストを書いたのも変異を設計したのも Claude Opus 5 です。対象の check.mjs は個人リポジトリのもので公開していないため、この実験そのものを第三者が丸ごと再現することはできません。上のハッシュは、あとからテストをいじっていないことを自分で確認するための記録です。

    1事例にすぎません。対象は168行・13ルールの1ファイルで、母集団を代表する標本ではない。

    20個のうち11個が「しきい値の±1」型なのは、ぼくが13ルールのしきい値を狙って当てた結果です。StrykerやPITを既定設定で回せば配合は変わり、スコアも変わります。この60.0%は「Strykerを回したらこうなる」という数字ではありません。

    今回は行・分岐のカバレッジ自体を測っていません。だからこの実験は「カバレッジが高くても有効性が低い」ことを直接検証したものでもない。

    JS/Nodeを対象にした「AI生成テスト×ミューテーションスコア」の査読済み研究は、今回の調査範囲では見つかりませんでした。既存研究の対象はJava、Python、Kotlinです。「存在しない」とは言えません。見つからなかった、というだけです。

    今日からできること


    手を動かす順に3つだけ。

    1. 自分のコードのしきい値を数える。 比較演算子と定数の組み合わせをgrepするだけでいい。今回ぼくのコードでは、そこにテストの穴が集中していました。

    2. しきい値1つにつき、境界の内側と外側を1件ずつ足す。 x < N なら N-1 と N、x > N なら N と N+1。判定が切り替わる両側を押さえます。

    3. 一番効くのは、わざと1文字ずらしてみることです。 ツールは要りません。実装の比較演算子か定数を1つ書き換えて、テストが赤くなるか見る。緑のままなら、そのテストはその変異を見ていない。30秒で終わります。

    JavaScriptならStrykerという専用ツールがありますが、まずは手で1個ずらすところからで十分だと思います。ぼくのハーネスも、実質は「置換して、走らせて、終了コードを見る」の3行でした。

    テストが緑であることは、安心してよい理由にはならない。赤くなるべきときに赤くなった実績があることが、安心してよい理由になる。今回いちばん腹落ちしたのはそこでした。




    AIに任せた工程を、実際のコードと数値で検証する記事を続けています。経験談だけでなく、手順と生データと限界まで見たい方はフォローしてもらえると届きます。note は designing_edges です。

    自分のコードでも試した方は、言語とコードの性質、そして検知できた数をコメントで教えてください。1事例だけでは見えない偏りが、事例が増えるほど比較できるようになります。関連記事も同じアカウントにまとめています。

    参考リンク


    ・Stryker Mutator(killed/survivedと等価ミュータントの定義) https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/ 
    ・等価ミュータントは自動判定できない https://stryker-mutator.io/docs/mutation-testing-elements/equivalent-mutants/ 
    ・PITのミューテータ一覧(CONDITIONALS_BOUNDARY / CRCR) https://pitest.org/quickstart/mutators/ 
    ・Inozemtseva & Holmes, "Coverage Is Not Strongly Correlated with Test Suite Effectiveness"(ICSE 2014) https://doi.org/10.1145/2568225.2568271 
    ・Petrović & Ivanković, "State of Mutation Testing at Google"(ICSE-SEIP 2018) https://doi.org/10.1145/3183519.3183521 
    ・Papadakis ら, ミューテーションスコアと実バグ検出の関係(ICSE 2018) https://doi.org/10.1145/3180155.3180183 
    ・LLM生成テストのミューテーション誘導(IEEE TSE採録) https://arxiv.org/abs/2506.02954 
    ・LLM生成テスト指標の再現研究(ISSTA 2026採録) https://arxiv.org/abs/2607.22880 
    ・Node.js テストランナー公式ドキュメント https://nodejs.org/docs/latest-v24.x/api/test.html 


    あなたへのおすすめ