AI を使ったテストコード自動生成ハンズオン:既存コードに後からテストを足す
概要
「テストを書きたいが時間がない」「レガシーコードにテストが一本もない」という状態を、AI で崩すためのハンズオンです。LLM にテストを丸投げするのではなく、人間が仕様の番人をやり、AI に反復作業を肩代わりさせるという分担で進めます。
扱う流れは次のとおりです。
- テスト対象の読み込ませ方(コンテキストの渡し方)
- ケース設計を先に出させて人間がレビューする
- 実際のテストコードを生成させる
- 生成テストを信用しないための検証(カバレッジ・ミューテーション)
- CI への組み込み
言語は Python(pytest)を主に、TypeScript(Vitest)の例も添えます。
環境
- Python 3.11 / pytest 8.x / pytest-cov / mutmut
- Node.js 20 / Vitest 1.x
- 任意の LLM(コード生成が可能なモデル。API/CLI いずれでも可)
掲載コードは手元の pytest / vitest で実行して通ることを確認した最小例です。
発生した問題:AI 任せのテストは「通るだけ」になりがち
AI に「このコードのテストを書いて」とだけ頼むと、よくこうなります。
- 実装をそのままなぞったトートロジーなテスト(実装のバグごと追認する)
- ハッピーパスだけで、境界値・異常系が抜ける
- モックを過剰に積んで、結局何も検証していない
- 存在しない関数やフィクスチャを呼ぶ(ハルシネーション)
「カバレッジは上がるのにバグは落ちる」という最悪の状態です。原因は、AI が実装を正解とみなしてしまうことにあります。
原因
テストの価値は「実装とは独立した仕様の表明」にあります。ところが実装コードだけを渡すと、AI はその実装の振る舞いを正としてアサーションを書く。実装が間違っていてもテストは緑になります。
鍵は 実装ではなく仕様を渡すこと、そして ケース設計を実装生成から分離することです。
対応方法
ステップ1:対象を決め、仕様を言語化する
割引計算というありがちなドメインロジックを例にします。
# pricing.py
def apply_discount(price: int, rate: float) -> int:
"""価格に割引率を適用して整数円で返す。
- price は 0 以上の整数(円)
- rate は 0.0〜1.0。範囲外は ValueError
- 端数は切り捨て(floor)
"""
if not (0.0 <= rate <= 1.0):
raise ValueError("rate must be between 0.0 and 1.0")
return int(price * (1 - rate))
docstring に仕様を書いておき、AI にはこの docstring を「正」として渡します。
ステップ2:まずケース表だけを出させる
いきなりコードを生成させず、「テストケースの一覧表」だけを出させます。
次の関数の docstring を仕様の正とみなし、pytest 用の テストケースを表で列挙してください。コードはまだ書かないでください。 各ケースに「区分(正常/境界/異常)」「入力」「期待結果」「狙い」を書いてください。 境界値と異常系を優先してください。
出てきた表を人間がレビューします。
| 区分 | 入力 | 期待結果 | 狙い |
|---|---|---|---|
| 正常 | price=1000, rate=0.1 | 900 | 基本動作 |
| 境界 | rate=0.0 | price と同値 | 下限 |
| 境界 | rate=1.0 | 0 | 上限 |
| 境界 | price=0 | 0 | 価格ゼロ |
| 境界 | price=999, rate=0.1 | 899 | 端数 floor |
| 異常 | rate=-0.1 | ValueError | 下限外 |
| 異常 | rate=1.5 | ValueError | 上限外 |
999 * 0.9 = 899.1 → 899 は端数切り捨てを突く良いケースです。抜けていれば人間が足します。仕様の抜けを埋めるのは人間の仕事です。
ステップ3:承認したケース表からテストコードを生成させる
# test_pricing.py
import pytest
from pricing import apply_discount
@pytest.mark.parametrize(
"price, rate, expected",
[
(1000, 0.1, 900),
(1000, 0.0, 1000),
(1000, 1.0, 0),
(0, 0.5, 0),
(999, 0.1, 899),
],
)
def test_apply_discount_normal(price, rate, expected):
assert apply_discount(price, rate) == expected
@pytest.mark.parametrize("rate", [-0.1, 1.5])
def test_apply_discount_invalid_rate(rate):
with pytest.raises(ValueError):
apply_discount(1000, rate)
ステップ4:TypeScript 版(Vitest)も同じ分担で
// pricing.ts
export function applyDiscount(price: number, rate: number): number {
if (rate < 0 || rate > 1) {
throw new Error("rate must be between 0 and 1");
}
return Math.floor(price * (1 - rate));
}
// pricing.test.ts
import { describe, it, expect } from "vitest";
import { applyDiscount } from "./pricing";
describe("applyDiscount", () => {
it.each([
[1000, 0.1, 900],
[1000, 0, 1000],
[1000, 1, 0],
[0, 0.5, 0],
[999, 0.1, 899],
])("applyDiscount(%i, %f) === %i", (price, rate, expected) => {
expect(applyDiscount(price, rate)).toBe(expected);
});
it.each([-0.1, 1.5])("throws on invalid rate %f", (rate) => {
expect(() => applyDiscount(1000, rate)).toThrow();
});
});
確認方法
カバレッジを測る
pytest --cov=pricing --cov-report=term-missing
Missing に未到達行が出ます。これを AI にフィードバックして「この行を踏むケースを足して」と頼むループが有効です。ただしカバレッジ 100% は「全行が実行された」だけで「正しく検証された」の保証ではありません。
ミューテーションテストで「テストの手抜き」を暴く
実装をわざと壊して(- を + にする等)、テストが失敗するかを見ます。失敗しなければ、そのテストは意味をなしていません。
pip install mutmut mutmut run --paths-to-mutate=pricing.py mutmut results
survived(生き残った変異)が出たら、それを殺すケースを AI に生成させます。AI が書いたテストを AI 以外の指標で検品する——これがトートロジー問題への実務的な対策です。
注意点
- 実装ではなく仕様を渡す。 仕様が無いなら、まず docstring やコメントに言語化する。これ自体が設計の棚卸しになる。
- ケース設計と実装を分ける。 表を人間が承認してからコードを書かせると、抜けと冗長が激減する。
- 生成されたフィクスチャ名・import を鵜呑みにしない。 存在しないものを呼ぶことがある。必ず実行して確認する。
- 機密を渡さない。 本番の接続情報・顧客データ・環境変数を含むコードをそのまま貼らない。対象を最小のモジュールに切り出して渡す。
- カバレッジを目的化しない。 ミューテーション生存率やレビューと組み合わせて初めて意味がある。
まとめ
AI によるテスト自動生成は「書かせる」より「設計を承認し、検品する」ワークフローにすると実用になります。
- 仕様を言語化して渡す
- ケース表を先に出させて人間がレビュー
- 承認後にコード生成
- カバレッジ+ミューテーションで検品
- 足りないケースをフィードバックしてループ
AI は反復と網羅の下書きが速く、人間は仕様の番人に専念できます。この分担が、テスト自動生成を実務で機能させる鍵です。
