Claude Code や Copilot でコードを生成するようになって、テストをどうするか、が前より気になるようになりました。
「カバレッジ100%を目指す時代は終わったのかもしれない」なんて言うと大げさですが、普通のユニットテストを書いていて、なんか物足りない感じがありました。思いついた入力パターンしかテストしていない、というか。
そこで、Property Based Testing(PBT)というのを試してみることにしました。
なぜ従来のユニットテストでは足りないのか
AI生成コードの特徴として、エッジケースの網羅が甘いことが多い。人間が書くテストも同様で、「思いついた入力パターン」しかテストしない。
// よくあるテスト
test('add positive numbers', () => {
expect(add(1, 2)).toBe(3);
expect(add(0, 0)).toBe(0);
});
// これで本当に十分?
この「思いついた入力パターンしかテストしない」のが問題だな、と。add(1, 2) が通っても、負数や小数や大きな数でどうなるかは見えていません。
PBTの基本的な考え方
PBTは「性質」をテストする。具体的な入出力ではなく、「常に成り立つべき法則」を検証する。
// fast-check を使った例
import fc from 'fast-check';
test('add is commutative', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return add(a, b) === add(b, a);
})
);
});
最初は「ランダムにテストしてるだけじゃないの?」と思ったのですが、性質を書くこと自体が思考を強制されるのが面白いです。「交換法則が成り立つ」を書くには、関数の性質をちゃんと理解していないと書けない。
Shrinkingが効く
PBTの真価は「失敗時」に発揮される。テストが失敗すると、ツールは自動的に「最小の失敗ケース」を探してくれる。
// 例: 配列の長さが100で失敗した場合
// shrinkingにより「長さ1で失敗する最小ケース」まで絞り込まれる
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return myFunction(arr).length <= arr.length;
})
);
// 失敗時: Counterexample: [42] ← 最小化された入力
これがデバッグ効率を劇的に上げる、というのは実感できました。失敗が「長さ100の配列で起きた」ではなく「[42]で起きた」まで絞られるので、原因を探す手間が全然違います。
言語別ツール選定
自分が触る言語で調べた限り、こんな感じです。
- TypeScript: fast-check が最も成熟。shrinkingが優秀で、失敗ケースの最小化が速い
- Python: Hypothesis がデファクト。Django統合もある
- Rust: proptest がマクロベースで書きやすい。quickcheckより柔軟
AI生成コードとの相性
Claude CodeやCopilotで生成したコードに対してPBTを書くと、驚くほどバグが見つかります。
理由は単純で、AIは「よくあるパターン」を学習しているが、境界条件や数学的性質までは考慮していないことが多い。
# Hypothesisの例
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
sorted_once = sorted(xs)
sorted_twice = sorted(sorted_once)
assert sorted_once == sorted_twice
実際、AIが生成したソートや計算ロジックにPBTを当てたら、境界条件で失敗するケースが何度かありました。生成コードほど、性質ベースのテストが効く気がします。
PBTの限界
万能ではないです。以下のケースは従来のテストの方が適している、というのが今の感触です。
状態を持つシステム
DBやファイルシステムを絡めたテストは、PBTだとセットアップが複雑になりすぎる。stateful testingという手法もあるが、学習コストが高い。
外部APIとの連携
モックの組み合わせ爆発が起きやすい。契約テスト(Pact等)の方が現実的。
UIの振る舞い
「ボタンを押したら画面遷移する」のような振る舞いは、性質として定義しにくい。
実務での使い分け
今のところ、こんな使い分けで回しています。
- 純粋関数・計算ロジック: PBTで性質をテスト
- 状態管理・副作用: 従来のユニットテスト
- UI/表示系: スナップショットテスト
- 統合テスト: 最小限のE2E
カバレッジは指標として見るが、100%を目指す工数は他に回す。
おわり
PBTが全部の答えではないです。ただ、AIが生成するコードが増えると、「思いついた入力パターン」のテストだけでは不安で、性質を書く方がしっくりきました。
今なら、AIが生成した関数の検証は、デフォルトでPBTを挟むのが良さそうです。言語ごとのツールは揃っているので、試すハードルも低めです。もっと慣れてきたら、stateful testingにも手を出してみたい。