LLM生成テストの誤検知を減らす:実装検証とモック設計の工夫
LLMにテストケースを自動生成させると、最初は「こんなに楽になるのか」と感動するはずです。ところが少し経つと、CIが緑なのにバグが出たり、何も壊れていないのにテストが赤くなったりする状況に直面します。これは偽陽性(フォールスポジティブ)と呼ばれる誤検知で、LLM生成テスト特有の問題です。手動で全件確認するのでは本末転倒ですが、完全に信頼するのも危険です。本記事では、誤検知が起きる原因を3つのパターンに整理したうえで、静的解析・モック設計・中間検証をどう組み合わせるかを具体的に解説します。
LLM生成テストが陥りやすい誤検知パターン
LLMが生成したテストコードが誤検知を起こす原因は、大きく3つに分類できます。
① 環境依存の仮定 LLMはトレーニングデータのコードパターンを参照するため、「ファイルパスがUnix形式」「タイムゾーンがUTC」「ポート3000が空いている」といった暗黙の前提を埋め込みがちです。ローカルでは通るのにCI環境だけ落ちる、という典型的な症状はここから来ます。
② 実装と乖離したエッジケース LLMは関数シグネチャや型定義だけから「ありそうなエッジケース」を推定します。結果として、実装上は到達不能なコードパスや、存在しない引数の組み合わせをテストするケースが生まれます。実行は通るものの、カバレッジ上は意味のないテストです。
③ モックの過剰適用・誤設定 外部依存を取り除こうとして、LLMはとにかくモックを貼ります。しかし「返り値が正しくない」「呼び出し回数の期待値が実装と合わない」といったモック設定のミスが、誤ったテスト結果の元凶になります。モックが増えるほど検証品質の担保が難しくなります。
防衛線の第一段階:静的解析とリント
誤検知を減らす最初の防衛線は、「生成されたコードを人間が書いたコードと同じルールで静的解析する」ことです。LLMの出力だからといって別扱いにする必要はなく、むしろ既存のリント設定を厳格に適用するのが有効です。
ESLint+TypeScriptのプロジェクトであれば、CIパイプラインのテスト生成ステップの直後に以下を挟みます。
# .github/workflows/test-gen.yml の一部
- name: Lint generated tests
run: |
eslint tests/generated/**/*.test.ts \
--rule '{"no-unused-vars": "error"}' \
--rule '{"jest/no-disabled-tests": "error"}' \
--rule '{"jest/no-focused-tests": "error"}'
特に no-disabled-tests と no-focused-tests は重要です。LLMは「動かせない部分をスキップさせて辻褄を合わせる」傾向があり、test.skip() や test.only() を無断で挿入することがあります。
型チェックも欠かせません。
tsc --noEmit --strict --project tsconfig.test.json
専用の tsconfig.test.json を用意して strict: true を強制すると、any の乱用や存在しないプロパティへのアクセスを検出できます。LLMは型エラーを回避するために as any を多用する傾向があるので、@typescript-eslint/no-explicit-any もエラーレベルに設定しておきましょう。
モック設計でLLMの暴走を制御する
LLMに「このモジュールのテストを書いて」と指示するだけでは、モック戦略が毎回バラバラになります。これを制御する実践的な方法がモックファクトリの事前定義です。
// tests/__mocks__/factories.ts
export const createMockUserRepository = (
overrides: Partial<UserRepository> = {}
): jest.Mocked<UserRepository> => ({
findById: jest.fn().mockResolvedValue(null),
save: jest.fn().mockResolvedValue({ id: "1", name: "test" }),
delete: jest.fn().mockResolvedValue(undefined),
...overrides,
});
このファクトリを tests/__mocks__/ に置いてプロンプトに含めると、LLMは独自モックを作らずにファクトリを使うようになります。プロンプトには次のような制約を加えます。
制約:モックは tests/__mocks__/factories.ts のファクトリ関数のみを使うこと。
jest.fn() を直接テストファイルに書かない。
さらに、スキーマ検証を挟むのも有効です。モックの返り値が実際のデータ型と一致しているかをZodなどで確認します。
import { z } from "zod";
import { UserSchema } from "@/schemas/user";
const mockReturnValue = { id: "1", name: "test" };
const result = UserSchema.safeParse(mockReturnValue);
if (!result.success) {
throw new Error(`Mock return value is invalid: ${result.error}`);
}
このパターンをテストのセットアップ共通処理に組み込むと、モック設定ミスによる偽陽性を削減できます。
テスト実行前の中間検証ステップ
静的解析とモック設計を整えたら、次は「テストを実行する前にテストを検証する」中間ステップです。
到達可能性チェック:変異テスト
LLMが生成したテストが実装と整合しているかを確認する手軽な方法が、変異テスト(Mutation Testing)です。Strykerなどを使ってソースコードに意図的なバグを注入し、LLM生成テストがそれを検出できるかを測ります。
npx stryker run --testRunner jest \
--mutate "src/**/*.ts" \
--testFiles "tests/generated/**/*.test.ts"
変異スコアが低いテストケースは「実装を検証していない偽テスト」の可能性が高く、優先的に見直す対象として浮かび上がります。
カバレッジ差分の確認
LLM生成テストを追加する前後でカバレッジレポートを比較し、「追加したのにカバレッジが増えていない行」がないかを確認します。Istanbul/nycのレポートをJSON形式で出力して差分を取るスクリプトをCIに組み込むと、無意味なテストの混入を自動検出できます。
# coverage-diff.sh
npx jest --coverage --json --outputFile=coverage-before.json
# (LLM 生成テストを追加)
npx jest --coverage --json --outputFile=coverage-after.json
node scripts/compare-coverage.js coverage-before.json coverage-after.json
失敗事例をプロンプトに反映させる継続的改善
運用を続けると、「このパターンの誤検知が繰り返し出る」という傾向が見えてきます。これをプロンプトエンジニアリングに還元するループを作ることが、検証品質を長期的に高める近道です。
CIでテスト失敗が発生した際に、次の情報を構造化して記録します。
{
"test_file": "tests/generated/user.service.test.ts",
"failure_type": "environment_dependency",
"description": "Assumes UTC timezone in Date assertion",
"prompt_patch": "日付のアサーションには toISOString() ではなく、タイムゾーン非依存の getTime() を使うか、jest.setSystemTime でタイムゾーンを明示的に固定すること。"
}
これらの記録を蓄積し、テスト生成プロンプトの冒頭に「過去の失敗から学んだ禁止パターン」セクションとして追記していきます。5〜10件程度のルールでも、数週間運用すると誤検知率が下がる効果を実感できるはずです。
prompt_patch フィールドを持つJSONファイル群を読み込んでプロンプトを動的に構築するスクリプトを用意すると、管理が楽になります。
// scripts/build-prompt.ts
const patches = await loadFailurePatches("./failure-log/*.json");
const prohibitions = patches
.map((p) => `- ${p.description}: ${p.prompt_patch}`)
.join("\n");
const finalPrompt = `
${BASE_PROMPT}
## 禁止パターン(過去の失敗事例より)
${prohibitions}
`;
まとめ:信頼度スコアで自動化の範囲を明示する
LLMテストケース生成を実運用に乗せるには、個々のテストファイルに信頼度スコアを持たせる考え方が有効です。スコアは以下の要素で構成できます。
| 指標 | 重み | 評価方法 |
|---|---|---|
| 静的解析パス率 | 高 | ESLint / tsc エラーなし |
| 変異スコア | 高 | Stryker の mutation score |
| カバレッジ増加率 | 中 | 追加前後の差分 |
| モックファクトリ準拠 | 中 | ファクトリ以外の jest.fn() 検出 |
| 環境依存フラグ | 低 | ハードコードされたパス・ポート・タイムゾーンの有無 |
スコアが閾値を下回るファイルは「レビュー必須」フラグを立て、閾値を超えたものだけを自動マージの対象にするというポリシーを設けると、人間のレビューコストを抑えながら検証品質を一定水準に保てます。完全な自動化ではなく、「信頼できる範囲を明示化した自動化」として運用するのが、現時点での現実的な落とし所です。