マガジン

AIが書いたテストは本当に守れているか:意味の薄いテストの見分け方

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

コードとテストの紙を見ながら、鉛筆で一箇所を確認している手
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

結論から言うと、テストが「通る」ことは、守れている証拠になりません。守れているかどうかは、本体のコードをわざと壊したときにテストが落ちるかで確かめられます。壊しても緑のままなら、そのテストはその部分を見張っていません。 AIは「通るテスト」を作るのが得意です。一方で、期待値を実装の出力からそのまま写したり、例外を握りつぶしたりして、何も確認しないテストも混ざります。この記事では、弱いテストの型を表で示し、読んだだけでは見抜けないものを壊して確かめる手順を紹介します。例はNode.js標準のテストランナーとGitで書いています。

この記事の目次
  1. 弱いテストの典型パターン表
  2. 本体をわざと壊して確かめる手順
  3. 説明用の例:同じ緑でも検出力が違う
  4. AIに任せること、人が確認すること
  5. 結果の判定表

弱いテストの典型パターン表

次の表は、コードを読んで見つけるための手がかりです。件数や頻度を調べたものではなく、編集上の整理です。

  • 真偽だけ見る、見た目の特徴:assert.ok(result)、なぜ弱いか:assert.ok は値が真とみなせるかを見るだけです。結果が間違っていても、0や空文字以外なら通ります、直し方:strictEqual や deepStrictEqual で具体値を比べる
  • 期待値の写経、見た目の特徴:期待値が実装と同じ式、または実行結果の貼り付け、なぜ弱いか:実装が間違っていると、テストも同じ間違いを正解にします、直し方:仕様や手計算から期待値を決める
  • 例外の握りつぶし、見た目の特徴:try { ... } catch {} の中に確認がない、なぜ弱いか:例外が出なくても、出ても通ります、直し方:assert.throws で種類まで指定する
  • await忘れ、見た目の特徴:assert.rejects(...) に await がない、なぜ弱いか:assert.rejects はPromiseを返すため、待たないとテストが完了する前に処理が終わり、失敗を見逃す恐れがあります、直し方:await を付ける
  • モックの確認だけ、見た目の特徴:呼ばれた回数だけ見る、なぜ弱いか:渡した値や、返り値を使った結果を見ていません、直し方:引数と最終結果も確認する
  • 正常系だけ、見た目の特徴:境界値や不正入力がない、なぜ弱いか:>= と > の間違いなどが通ります、直し方:境界の前後と不正入力を足す
  • skip / todo、見た目の特徴:{ skip: true }、{ todo: true }、なぜ弱いか:skipしたテストは実行されず、todoのテストは失敗しても全体は失敗扱いになりません(Node.js test runner)、直し方:理由を確認し、残すなら期限を決める

node:assert/strict は厳密な比較を使うモードです(Node.js assert)。

本体をわざと壊して確かめる手順

読んで怪しいテストだけでなく、緑のテスト全体にも使える方法です。1回に壊すのは1か所だけにします。

  1. git status で作業ツリーを確認します。別の変更がある場合は、退避する内容を確認してから git stash push -u で退避します。-u を付けると未追跡ファイルも退避できます(git stash)。
  2. node --test を実行し、壊す前は全部通ることを確認します。
  3. 本体を1か所だけ変えます。比較演算子の変更、+ と - の入れ替え、条件分岐の削除、返り値の固定などです。
  4. もう一度 node --test を実行します。
  5. 落ちれば、その壊し方は検出されています。緑のままなら、見逃しです。
  6. git restore 対象ファイル で本体を戻します。この操作は、対象ファイルの作業ツリーの内容をインデックスの内容に戻すため、そこにある未コミットの変更も失われます(git restore)。壊す前にコミットするか、変更を別途退避しておきます。
  7. 退避していたなら git stash pop で戻します。競合が起きた場合はstashが残るため、内容を確認して手動で解決します。

カバレッジ(node --test --experimental-test-coverage)はコードの実行状況を集計します。ただし、実行されても値を確認していなければ守れていません。壊す手順はそこを補います(Node.js test runner)。

説明用の例:同じ緑でも検出力が違う

次は説明用の例で、実際に実行した結果ではありません。割引額を計算する関数と、弱いテスト、強いテストです。

js
// price.mjs
export function applyDiscount(price, rate) {
  if (price < 0 || rate < 0 || rate > 1) throw new RangeError('invalid');
  return Math.round(price * (1 - rate));
}
js
// price.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyDiscount } from './price.mjs';

test('弱い例', () => {
  assert.ok(applyDiscount(1000, 0.1));
  try { applyDiscount(-1, 0.1); } catch {}
});

test('強い例', () => {
  assert.strictEqual(applyDiscount(1000, 0.1), 900);
  assert.strictEqual(applyDiscount(1000, 1), 0);
  assert.throws(() => applyDiscount(-1, 0.1), RangeError);
  assert.throws(() => applyDiscount(1000, 1.1), RangeError);
});

ここで次の2つの壊し方を試すとします。コードを読んだ予想は次のとおりです。

  • 1 - rate を 1 + rate にする、弱い例:1100は真なので通る、強い例:900と比べて落ちる
  • rate > 1 を rate > 2 にする、弱い例:例外を確認していないので通る、強い例:1.1で例外が出ず落ちる

弱い例は、壊しても緑のままになるはずです。実際のプロジェクトでは、自分の環境で必ず実行して確かめてください。

AIに任せること、人が確認すること

壊す箇所の候補出しはAIに任せられます。実行と判断は人が行います。

弱いテストの洗い出しを頼む依頼文

次のテストを、弱いテストの型で点検してください。
型: 真偽だけ確認、期待値の写経、例外の握りつぶし、await忘れ、
モックの回数だけ確認、境界値なし、skip/todo。
該当する行番号、理由、直し方を表で出してください。
実行はせず、読んで分かる範囲だけ答えてください。

[対象のコードとテストを貼る]

壊す箇所の候補を頼む依頼文

次の関数について、テストが検出すべき小さな変更を5つ挙げてください。
各案は1か所だけの変更とし、変更前後のコードと、
その変更でどの仕様が破れるかを書いてください。
ファイルは書き換えないでください。

[対象の関数を貼る]

渡す情報は、対象の関数とテストだけで十分です。APIキーや顧客データを含むコードは、ダミーに置き換えてから貼ってください。

人が確認するのは次の点です。

  • 候補の壊し方が、本当に仕様を破っているか。壊しても結果が同じ変更(同値になる変更)は、見逃しとは数えません。
  • 実行して落ちたか、緑のままだったか。
  • 戻したあとに git diff が空か。

結果の判定表

  • 落ちた、意味:その壊し方は検出された、次の一手:次の候補へ進む
  • 緑のまま、仕様が破れている、意味:検出力の穴、次の一手:破れを検出するアサーションを足し、もう一度壊して落ちることを確認する
  • 緑のまま、動作が同じ、意味:同値の変更、次の一手:候補から外す
  • 壊す前から落ちる、意味:前提が崩れている、次の一手:先にテストか本体を直す

穴を見つけたら、AIに「この壊し方を検出するテストを足して」と頼めます。ただし、その場合も必ず同じ壊し方で落ちるか再確認してください。落ちることを確かめて初めて、足したテストは役に立つと言えます。

出典・参考資料