マガジン

AIが書いたテストは本当に不具合を見つけるか:わざと入れた不具合で確かめる実験設計

遊びをつくる

執筆にClaudeを使用しています。

小さな迷路の壁を一か所取り外し、通り道を確かめる模型。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

AIに「この関数のテストを書いてください」と頼むと、整った見た目のテストが数十行で返ってきます。実行すると全部緑で、安心しそうになります。ただ、全部通ることと、不具合を見つけられることは別の話です。正しい実装に対して通るテストは、まちがった実装にも通ってしまうことがあります。 この記事では、その差を測る実験を設計します。やることは単純で、正しい関数にわざと小さな不具合を一つずつ入れた「壊した版」を用意し、AIが書いたテストがそれを見つけて赤になるかを数えます。テスト側の力を、壊した版の検出数で測る方法です。 結果はこの記事には書きません。AIの出力は依頼文や回ごとに変わるため、数字はお手元の実行でしか出ません。以下は、その数字を出すための準備、手順、記録の取り方、読み方です。

この記事の目次
  1. 用語を三つだけ決めておきます
  2. 準備するもの
  3. 手順
  4. 何をどの単位で記録するか
  5. 結果の読み方
  6. 起こりやすい誤り
  7. 次の行動

用語を三つだけ決めておきます

実験で使う言葉は、次の三つです。

  • 変異版(ミュータント):正しい実装に、一か所だけ小さな変更を入れた版です。係数を変える、比較の向きを変える、上限の処理を消す、といった変更です。
  • 検出:変異版に対してテストが一つ以上失敗することです。
  • 生存:変異版に対しても全テストが通ってしまうことです。テストが不具合を見逃した状態です。

測るのは「変異版のうち、いくつを検出できたか」です。この手法は一般に変異テストと呼ばれます。本格的なツールもありますが、ここでは数個の変異版を手で用意し、数十行のコードで足ります。

準備するもの

題材の関数

ゲームの部品から、入力と出力がはっきりした純粋な関数を一つ選びます。乱数、時間、画面描画に依存しないものが向いています。得点計算、ダメージ計算、勝敗判定、盤面の範囲チェックなどです。

この記事では例として、次の仕様のスコア計算を使います。

基礎点 base(0以上の整数。100を超える値も渡されます)から、ミス1回につき30点、ヒント1回につき15点を引きます。結果は0〜100に収めます。

変異版の一覧を、AIに見せる前に決める

変異版は、AIに依頼する前に作って伏せておきます。見せてしまうと、そこを狙ったテストが返ってきて、普段のテスト生成の力を測れなくなります。

変異の種類を散らすのがコツです。次の四種類を混ぜると、テストの弱点が分類しやすくなります。

  • 係数の変更(30を20にする)
  • 処理の欠落(下限や上限の丸めを消す)
  • 項目の無視(ヒントの減点を無視する)
  • 境界の変更(< を <= にする)

実行環境

ブラウザの開発者ツールのコンソールに貼るか、Node.jsで実行します。外部ライブラリは使いません。次のコードを手元に置きます。

js
const clamp = (s) => Math.min(100, Math.max(0, s));
const versions = {
  正本: (b, m, h) => clamp(b - m * 30 - h * 15),
  M1_ミス係数: (b, m, h) => clamp(b - m * 20 - h * 15),
  M2_ヒント係数: (b, m, h) => clamp(b - m * 30 - h * 10),
  M3_下限なし: (b, m, h) => Math.min(100, b - m * 30 - h * 15),
  M4_上限なし: (b, m, h) => Math.max(0, b - m * 30 - h * 15),
  M5_ヒント無視: (b, m) => clamp(b - m * 30),
  M6_境界: (b, m, h) => {
    const s = b - m * 30 - h * 15;
    return s <= 0 ? 0 : Math.min(100, s);
  },
};
// AIが書いたテストをここに貼る(f は検査対象の関数)
const tests = [
  ["満点", (f) => f(100, 0, 0) === 100],
  ["ミス1回", (f) => f(100, 1, 0) === 70],
];
console.log("正本で失敗:", tests.filter(([, t]) => !t(versions.正本)).map(([n]) => n));
for (const [name, f] of Object.entries(versions)) {
  if (name === "正本") continue;
  const hit = tests.filter(([, t]) => !t(f)).map(([n]) => n);
  console.log(name, hit.length ? "検出 " + hit.join("/") : "生存");
}

tests に入れている2件は、書式を示すための例です。AIのテストは、この配列の形(名前と、関数を受け取って真偽を返す関数の組)に直して差し替えます。

手順

  1. 依頼文を固定します。 仕様の文章と「(f) を受け取って真偽を返す形で、名前付きテストの配列を書いてください」という出力形式を、毎回まったく同じ文で渡します。比べたい条件以外は変えません。
  2. 正本で全件通ることを確認します。 正本で失敗するテストは、テスト側の誤りです。その場で外すか直し、外した件数を記録します。ここを飛ばすと、変異版で「検出」と出ても、壊れたテストが赤くなっているだけかもしれません。
  3. 変異版を一つずつ実行します。 上のコードが自動で行います。一度に複数の変更を入れず、一つの変異版には一か所だけ変更を入れます。
  4. 等価変異を手で判定します。 変異版が正本と全入力で同じ答えを返すなら、それはどのテストでも検出できません。上の M6_境界 は、その候補です。s <= 0 と s < 0 の違いが結果に出るか、s が0のときの値を紙に書いて確かめてください。等価と判断した変異版は、分母から除きます。
  5. 試行を3回繰り返します。 同じ依頼文で、新しい会話から3回テストを生成します。AIの出力は回ごとに揺れるため、1回では傾向が読めません。
  6. 条件を変えてもう一巡します。 後述する「仕様だけ渡す」と「仕様と実装コードを渡す」を比べます。

何をどの単位で記録するか

1回の生成ごとに1行、次の列で記録します。

  • 条件、内容:仕様のみ/仕様+コード
  • 試行番号、内容:1〜3
  • テスト件数、内容:貼り付けた件数
  • 正本での失敗、内容:件数と原因
  • 検出した変異版、内容:M1〜M6 の名前
  • 生存した変異版、内容:同上
  • 等価と判断した変異版、内容:同上
  • 検出率、内容:検出数 ÷(変異版の総数 − 等価の数)

あわせて、生存した変異版ごとに「どの種類の変更か」を一言メモします。係数、欠落、無視、境界のどれかです。集計の主役は検出率より、このメモの偏りです。

結果の読み方

数字が出たら、次の順で読みます。

検出率が低いとき

テストが「仕様の例を一つ写しただけ」になっている可能性があります。生存した変異版の種類を見てください。

  • 係数の変更が生き残る:ミスとヒントを別々に動かす入力がありません。ミスだけ、ヒントだけ、両方、の3系統が必要です。
  • 欠落が生き残る:値が0を下回る入力や、100を超える入力が抜けています。仕様に書かれた「範囲」の両端を試しているかを見ます。
  • 項目の無視が生き残る:ヒントを1以上にした入力が1件もありません。
  • 境界が生き残る:ちょうど0になる入力がありません。ただし等価変異ならこの指摘は当たらないので、手順4の判定と照らしてください。

検出率が高いのにテスト件数が多すぎるとき

件数が多いのに検出が増えないなら、同じ種類の入力を繰り返しているだけです。重複が多いテストは、保守の手間が増えるだけで守りは増えません。1件ずつ外して検出数が変わるかを見ると、必要な件数が分かります。

二つの条件の差

「仕様のみ」より「仕様+コード」のほうが検出率が低い、あるいは差がないなら、コードを見せたことでテストが実装の写しになった可能性があります。逆に高くなるなら、コードから境界を読み取れていると考えられます。ただし3回の試行では、差が偶然の揺れの範囲かどうかは断定できません。差が小さいときは、試行を増やしてから判断してください。

起こりやすい誤り

  • 期待値を実装と同じ式で出している。 f(b, m, h) === clamp(b - m * 30 - h * 15) のようなテストは、正本が間違っていても通ります。変異版が係数だけ変えた場合は検出できますが、仕様を誤読した実装は守れません。期待値は仕様から手で計算した固定の数値で書かれているかを確認してください。
  • 変異版を先にAIへ見せてしまう。 検出率が上がっても、それは普段のテスト生成の力ではありません。
  • 変異版の数が少なすぎる。 6個前後で傾向は見えますが、1個の生存で検出率が大きく動きます。率より、生存したものの中身を読んでください。
  • 仕様に入力の範囲が書かれていない。 この記事の例では「100を超える値も渡されます」と明記しました。この一文がなければ、M4_上限なし を検出する入力は仕様上あり得ない、という議論になります。テストの弱さではなく、仕様の曖昧さが原因のこともあります。
  • 生存を見て終わりにする。 生存した変異版は、AIに「この変更を入れた版が全テストを通りました。見逃した理由を挙げ、足すべきテストを書いてください」と返すと、次のテストが得られます。この往復で検出率がどう動くかも、同じ表に記録しておくと有用です。

次の行動

まず、手元のゲームから得点計算かダメージ計算の関数を一つ選び、仕様を3行で書いてください。次に、その関数の変異版を4〜6個作り、AIには見せずに伏せておきます。そして、同じ依頼文でテストを3回生成して表に記録します。

ゲームを公開する前の確認として、この手順を勝敗判定のように「間違うと遊びが壊れる」関数に絞って使うのが現実的です。投稿は入力、表示の確認、公開の3段階なので、表示の確認の前に関数単位で検証しておくと、確認の段階で見るべき点を減らせます。