TL;DR
- Property-based testing(PBT)は、ランダム生成した入力でコードの「不変条件」を検証する手法。例題ベーステストの5〜10倍のカバレッジが得られる(経験則: Hypothesis公式ブログ)
- AI生成コードは「エッジケースへの配慮が薄い」傾向があり、PBTとの相性が特に良い
- PythonはHypothesisが事実上の標準(公式値: PyPI monthly downloads 500万+)。JavaScriptはfast-checkがTypeScript対応で使いやすい(公式リポジトリ)
- 失敗ケースの「縮小(shrinking)」機能により、最小再現例を自動生成してデバッグが容易になる
- CI/CDへの組み込みは
max_examplesと--hypothesis-seedで実行時間と再現性を制御する
はじめに
こんにちは、みねです。
AIコーディングエージェントを使っていると、こんな場面に遭遇します。
「AIが書いたソート関数、基本的なテストは通るんだけど、重複要素やUnicode文字が混じると挙動がおかしくなった」
「LLMに変換ロジックを書かせたら、空文字列やnullを渡したときにクラッシュした。テストが足りなかった」
AIが生成するコードは、「よくある入力」に対しては正しく動くことが多い。しかし、エッジケースへの配慮が薄いという共通した傾向があります。これは、LLMの学習データが「正常系の例」に偏っているためです。
一方、従来の例題ベーステスト(Example-based testing)では、テストを書く人間が思いつかなかったケースは検出できません。「空のリスト」「負の数」「制御文字を含む文字列」——これらを漏れなくカバーするのは、人間には難しい。
Property-based testing(PBT) は、この問題を根本から解決するアプローチです。入力を手動で書く代わりに、入力の「範囲」を宣言し、コンピュータが何千もの入力を自動生成して検証します。
AI生成コードのテスト戦略で「テスト設計パターン」として取り上げたPBTを、本記事では**Hypothesis(Python)とfast-check(JavaScript)**を使って実践的に解説します。
1. Property-based testingとは何か
1-1. 例題ベーステストの限界
従来のunitテストは次のような形です。
def test_sort_basic():
assert sort([3, 1, 2]) == [1, 2, 3]
assert sort([]) == []
assert sort([1]) == [1]
これは「テストを書いた人が想定したケース」しか検証しません。では、次のケースはどうでしょう。
- 同じ値が複数あるリスト:
[3, 1, 2, 1] - 負の数:
[-1, -3, 0] - 非常に大きなリスト: 10,000要素
- Unicode文字を含む文字列のリスト:
["🍎", "apple", "Apple"]
これらをすべて手動で書くのは現実的ではありません。
1-2. PBTの仕組み:仮説と自動探索
PBTでは、「具体的な入力」の代わりに「プロパティ(不変条件)」を定義します。
ソートのプロパティ例:
- 出力の長さは入力と同じ
- 出力は昇順に並んでいる
- 出力の要素集合は入力と同じ(要素が消えたり増えたりしない)
# 例題ベーステスト:3ケースしか検証しない
def test_sort_examples():
assert sort([3, 1, 2]) == [1, 2, 3]
# プロパティテスト:Hypothesisが自動生成した何百ものケースで検証
from hypothesis import given
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_sort_properties(lst):
result = sort(lst)
assert len(result) == len(lst) # 長さ不変
assert all(result[i] <= result[i+1] for i in range(len(result)-1)) # 昇順
assert sorted(result) == sorted(lst) # 要素保存(multiset等価)
PBTライブラリは失敗を見つけると、shrinking(縮小) を自動実行します。つまり、「失敗する最小の入力」を自動的に求めてくれます。1000要素のリストで失敗を見つけたら、それを[0, -1]のような最小形に縮小してレポートします。
1-3. AI生成コードとPBTの相性
AI生成コードに対してPBTが特に有効な理由は3つあります。
- 境界条件の見落とし: LLMは「一般的なケース」から学習するため、
None、空コレクション、ゼロ除算などの境界を見落としやすい - 不変条件の明示化: PBTを書く過程で「この関数は何を保証すべきか」を明確にする必要があり、仕様のあいまいさを発見できる
- 回帰防止: LLM出力の品質ゲート設計で述べた「プロンプト変更による既存機能の劣化」をPBTで検出できる
2. Hypothesis(Python)で始めるPBT
HypothesisはPythonのPBTライブラリの事実上の標準です(公式値: PyPI monthly downloads 500万以上)。pytest、unittest両方と統合できます。
2-1. インストールと基本セットアップ
pip install hypothesis
# pytestと使う場合(推奨)
pip install hypothesis pytest
設定ファイル(pyproject.toml):
[tool.hypothesis]
max_examples = 100 # デフォルトは100。CI高速化なら50程度に
deriving = true
2-2. 最初のプロパティテスト
from hypothesis import given, settings
import hypothesis.strategies as st
# AI生成:文字列を逆順にする関数
def reverse_string(s: str) -> str:
return s[::-1]
# プロパティ1:2回逆順にすると元に戻る(ラウンドトリップ)
@given(st.text())
def test_reverse_is_involution(s):
assert reverse_string(reverse_string(s)) == s
# プロパティ2:長さは変わらない
@given(st.text())
def test_reverse_preserves_length(s):
assert len(reverse_string(s)) == len(s)
# プロパティ3:空文字は空文字になる
@given(st.just(""))
def test_reverse_empty(s):
assert reverse_string(s) == ""
st.text()は、ASCII文字だけでなくUnicode、絵文字、制御文字など多様な文字列を生成します。人間が手動で思いつかないケースをカバーできます。
2-3. AI生成ソート関数をHypothesisで検証
次のような、AIが生成したソート実装を検証する例です。
# AI生成コード(バグあり)
def ai_generated_sort(lst: list) -> list:
"""AIが生成したソート関数。バブルソート実装。"""
n = len(lst)
result = lst.copy()
for i in range(n):
for j in range(0, n-i-1):
if result[j] > result[j+1]:
result[j], result[j+1] = result[j+1], result[j]
return result
# Hypothesisでプロパティ検証
from hypothesis import given, settings
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_sort_output_is_sorted(lst):
"""出力が昇順に並んでいること"""
result = ai_generated_sort(lst)
for i in range(len(result) - 1):
assert result[i] <= result[i+1], f"Failed: {result}"
@given(st.lists(st.integers()))
def test_sort_preserves_elements(lst):
"""要素が保存されること(消えたり増えたりしない)"""
result = ai_generated_sort(lst)
assert sorted(result) == sorted(lst)
@given(st.lists(st.integers()))
def test_sort_same_as_builtin(lst):
"""Pythonの組み込みsortedと同じ結果を返すこと"""
assert ai_generated_sort(lst) == sorted(lst)
@given(st.lists(st.floats(allow_nan=False)))
def test_sort_with_floats(lst):
"""浮動小数点でも正しく動くこと(NaNは除外)"""
result = ai_generated_sort(lst)
assert result == sorted(lst)
2-4. Stateful testingで状態遷移を探索
より複雑な例として、AIが生成したスタック実装の検証を示します。
from hypothesis.stateful import RuleBasedStateMachine, rule, initialize, invariant
import hypothesis.strategies as st
# AI生成コード
class AIStack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def pop(self):
if not self._data:
raise IndexError("pop from empty stack")
return self._data.pop()
def peek(self):
if not self._data:
raise IndexError("peek at empty stack")
return self._data[-1]
def size(self):
return len(self._data)
# Stateful testingで状態遷移を検証
class StackMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.stack = AIStack()
self.model = [] # 参照実装(Pythonリスト)
@rule(value=st.integers())
def push(self, value):
self.stack.push(value)
self.model.append(value)
@rule()
def pop(self):
if self.model:
expected = self.model.pop()
actual = self.stack.pop()
assert actual == expected
@invariant()
def sizes_match(self):
"""スタックのサイズは常にモデルと一致する"""
assert self.stack.size() == len(self.model)
TestStack = StackMachine.TestCase
3. fast-check(JavaScript)で始めるPBT
fast-checkはTypeScriptファーストのPBTライブラリです(公式GitHub)。Jest、Vitest、Mochaと統合できます。
3-1. インストールとJest連携
npm install --save-dev fast-check
# または
pnpm add -D fast-check
Jest設定への追記は不要。既存のJestテストファイルでimportするだけで使えます。
3-2. 基本的なプロパティテスト
import * as fc from "fast-check";
// AI生成:文字列を逆順にする関数
function reverseString(s: string): string {
return s.split("").reverse().join("");
}
test("reverse is involution (2回適用で元に戻る)", () => {
fc.assert(
fc.property(fc.string(), (s) => {
expect(reverseString(reverseString(s))).toBe(s);
})
);
});
test("reverse preserves length", () => {
fc.assert(
fc.property(fc.string(), (s) => {
expect(reverseString(s)).toHaveLength(s.length);
})
);
});
3-3. AI生成APIレスポンス変換をfast-checkで検証
AIが生成したJSON変換ロジックをfast-checkで検証する実践例です。
import * as fc from "fast-check";
// AI生成コード:APIレスポンスをアプリ内形式に変換
interface ApiUser {
user_id: number;
first_name: string;
last_name: string;
email_address: string;
}
interface AppUser {
id: number;
fullName: string;
email: string;
}
function transformUser(api: ApiUser): AppUser {
return {
id: api.user_id,
fullName: `${api.first_name} ${api.last_name}`,
email: api.email_address,
};
}
// プロパティテスト
test("transformUser preserves user identity", () => {
const apiUserArb = fc.record({
user_id: fc.integer({ min: 1 }),
first_name: fc.string({ minLength: 1 }),
last_name: fc.string({ minLength: 1 }),
email_address: fc.emailAddress(),
});
fc.assert(
fc.property(apiUserArb, (apiUser) => {
const appUser = transformUser(apiUser);
// ID保存
expect(appUser.id).toBe(apiUser.user_id);
// email保存
expect(appUser.email).toBe(apiUser.email_address);
// 名前結合
expect(appUser.fullName).toContain(apiUser.first_name);
expect(appUser.fullName).toContain(apiUser.last_name);
})
);
});
test("transformUser handles edge cases", () => {
// スペースを含む名前(名前の区切りが壊れないか)
fc.assert(
fc.property(
fc.record({
user_id: fc.integer({ min: 1 }),
first_name: fc.string({ minLength: 0 }), // 空文字も含む
last_name: fc.string({ minLength: 0 }),
email_address: fc.emailAddress(),
}),
(apiUser) => {
// 例外が発生しないこと
expect(() => transformUser(apiUser)).not.toThrow();
}
)
);
});
3-4. モデルベーステストで副作用を探索
import * as fc from "fast-check";
// AI生成コード:簡易カウンター
class Counter {
private count: number = 0;
increment(n: number = 1): void {
this.count += n;
}
decrement(n: number = 1): void {
this.count -= n;
}
getValue(): number {
return this.count;
}
reset(): void {
this.count = 0;
}
}
// モデルベーステスト
test("Counter behaves like a mathematical counter", () => {
const commands = [
// incrementコマンド
fc
.integer({ min: 1, max: 100 })
.map((n) => ({
check: (_model: number) => true,
run: (model: { count: number }, real: Counter) => {
real.increment(n);
model.count += n;
expect(real.getValue()).toBe(model.count);
},
toString: () => `increment(${n})`,
})),
// decrementコマンド
fc
.integer({ min: 1, max: 100 })
.map((n) => ({
check: (_model: number) => true,
run: (model: { count: number }, real: Counter) => {
real.decrement(n);
model.count -= n;
expect(real.getValue()).toBe(model.count);
},
toString: () => `decrement(${n})`,
})),
// resetコマンド
fc.constant({
check: (_model: number) => true,
run: (model: { count: number }, real: Counter) => {
real.reset();
model.count = 0;
expect(real.getValue()).toBe(0);
},
toString: () => "reset()",
}),
];
fc.assert(
fc.property(fc.array(fc.oneof(...commands), { maxLength: 20 }), (cmds) => {
const model = { count: 0 };
const real = new Counter();
cmds.forEach((cmd) => cmd.run(model, real));
})
);
});
4. AI生成コードへのPBT適用パターン
4-1. 純粋関数:入出力の数学的性質を検証
AIが生成した純粋関数には「不変条件」が成り立つことが多い。代表的なパターン:
| パターン | 内容 | 例 |
|---|---|---|
| べき等性 | f(f(x)) == f(x) | deduplicate, normalize |
| 交換律 | f(a, b) == f(b, a) | add, merge |
| 結合律 | f(f(a,b), c) == f(a, f(b,c)) | concat, compose |
| ラウンドトリップ | decode(encode(x)) == x | serialize, compress |
4-2. エンコード/デコード:ラウンドトリップ検証
from hypothesis import given
import hypothesis.strategies as st
import json
# AI生成コード:カスタムJSONシリアライザー
def custom_serialize(data: dict) -> str:
return json.dumps(data, ensure_ascii=False, sort_keys=True)
def custom_deserialize(s: str) -> dict:
return json.loads(s)
# ラウンドトリップ検証
@given(st.dictionaries(
keys=st.text(min_size=1),
values=st.one_of(st.integers(), st.text(), st.booleans(), st.none()),
max_size=10
))
def test_serialize_roundtrip(data):
"""シリアライズ→デシリアライズで元に戻ること"""
serialized = custom_serialize(data)
deserialized = custom_deserialize(serialized)
assert deserialized == data
4-3. LLMパーサー:任意入力への耐性検証
LLMが生成したパーサーコードは、「正常な入力」では正しく動くが、任意入力でクラッシュしやすい。
from hypothesis import given, settings
import hypothesis.strategies as st
# AI生成コード:コマンドライン引数パーサー
def parse_config(text: str) -> dict:
"""key=value形式のテキストをパースする"""
result = {}
for line in text.strip().split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
result[key.strip()] = value.strip()
return result
# 任意テキストでクラッシュしないことを検証
@given(st.text())
def test_parse_config_never_crashes(text):
"""どんな入力でも例外を投げないこと"""
try:
result = parse_config(text)
assert isinstance(result, dict)
except Exception as e:
# 特定の例外のみ許容する場合はここで絞る
raise AssertionError(f"Unexpected exception: {e}")
4-4. 数値計算:境界条件の自動発見
import * as fc from "fast-check";
// AI生成コード:割引率計算
function calculateDiscountedPrice(
originalPrice: number,
discountPercent: number
): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error("Discount must be between 0 and 100");
}
return originalPrice * (1 - discountPercent / 100);
}
test("discounted price is always <= original price", () => {
fc.assert(
fc.property(
fc.float({ min: 0.01, max: 10000, noNaN: true }),
fc.float({ min: 0, max: 100, noNaN: true }),
(price, discount) => {
const result = calculateDiscountedPrice(price, discount);
// 割引後は元の価格以下
expect(result).toBeLessThanOrEqual(price + 0.001); // 浮動小数点誤差考慮
// 割引後は負にならない
expect(result).toBeGreaterThanOrEqual(0);
}
)
);
});
5. CI/CDへの組み込みと運用Tips
5-1. 実行時間制御とシード固定
PBTはデフォルトで何百もの入力を生成するため、CIで時間がかかることがあります。
Python(Hypothesis):
from hypothesis import given, settings, HealthCheck
import hypothesis.strategies as st
@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
@given(st.lists(st.integers()))
def test_sort_fast(lst):
"""CI向けに例数を制限"""
assert ai_generated_sort(lst) == sorted(lst)
シード固定で再現性確保(CI環境):
# 特定のシードで実行(失敗を再現するとき)
pytest --hypothesis-seed=12345 tests/test_sort.py
JavaScript(fast-check):
// グローバル設定
fc.configureGlobal({ numRuns: 50 }); // デフォルト100→50に
// テスト個別設定
fc.assert(
fc.property(fc.string(), (s) => { /* ... */ }),
{ numRuns: 50, seed: 42 } // シード固定
);
5-2. 失敗例の縮小(shrinking)活用
PBTの強力な機能の一つがshrinkingです。失敗を見つけた後、自動的に最小の失敗ケースを求めてくれます。
Falsifying example: test_sort_properties(
lst=[1000000, -999999, 0, 1, -1, 2147483647]
)
Shrunk to:
lst=[0, -1]
このshrinking結果はそのままバグレポートとして使えます。AIコードレビューのワークフローに組み込むと、AIが生成したバグの最小再現例を自動でチケットに貼れます。
5-3. 他のテスト手法との使い分け
| 手法 | 向いているケース | 不向きなケース |
|---|---|---|
| 例題ベーステスト | 仕様の具体例を文書化したい | エッジケースの網羅 |
| Property-based testing | 不変条件が明確な純粋関数・変換処理 | 副作用が多い・外部依存が多い |
| ファジング | セキュリティ境界・プロトコルパーサー | 通常の業務ロジック |
| ミューテーションテスト | テストスイートの品質評価 | テスト自体の作成 |
AI開発における計画とテスト設計で述べた「ケーパビリティ別テスト設計」において、PBTは純粋な変換ロジックに最も効果的です。
比較表:PBT vs 例題ベーステスト vs ファジング
| 観点 | 例題ベーステスト | Property-based testing | ファジング |
|---|---|---|---|
| テスト入力 | 手動で記述 | ライブラリが自動生成 | ツールが自動生成 |
| 失敗時 | 失敗ケースが明確 | shrinkingで最小ケースを提示 | クラッシュログのみ |
| 学習コスト | 低 | 中(プロパティ設計が必要) | 中(セットアップが必要) |
| AI生成コードへの適合 | 低(エッジケース見落とし) | 高(自動探索) | 中(クラッシュのみ) |
| CI統合 | 容易 | 容易(時間調整が必要) | 複雑 |
| 代表ライブラリ | pytest, Jest | Hypothesis, fast-check | AFL, libFuzzer |
FAQ
Q1. Property-based testingは例題ベーステストの代替ですか?
いいえ、補完関係です。例題ベーステストは「仕様の具体例を文書化する」役割があり、PBTは「エッジケースを自動探索する」役割があります。理想的には両方を使います。
Q2. HypothesisとpytestをCIで使うとき、実行時間が心配です。
@settings(max_examples=50)でCI向けに例数を減らせます。また、--hypothesis-seedでシードを固定すると毎回同じテストが実行されます。ローカルでは例数を増やして探索し、CIでは固定シードで高速実行する使い分けが実務での標準的なアプローチです(経験則: Hypothesis公式ドキュメント推奨パターン)。
Q3. fast-checkはJestだけでなくVitestでも使えますか?
はい。fast-checkはテストランナーに依存しない設計です(公式値: fast-check公式ドキュメント)。fc.assertはどのアサーションライブラリとも組み合わせられます。
Q4. PBTでどんなバグが見つかりやすいですか?
AI生成コードで特に多いのは:(1)空のコレクション/文字列への未対応、(2)整数オーバーフロー、(3)Unicode・マルチバイト文字の誤処理、(4)ゼロ除算、(5)nullチェック漏れです。これらはすべてHypothesisのst.text()、st.integers()が自動的に生成するエッジケースでカバーされます。
Q5. LLMが生成したコードに対してPBTを書くのもAIに任せられますか?
できます。「この関数の不変条件を3つ挙げてHypothesisテストとして書いてほしい」というプロンプトは有効です。ただし、AIが生成したプロパティテストは「論理的に自明なプロパティ」に偏りがちなので、人間が「本当に重要なプロパティを漏らしていないか」を確認する必要があります。
👉 シリーズ全体像: AIコードレビューの体系
まとめ
AI生成コードのエッジケースは、例題ベーステストだけでは発見が難しい。Property-based testingは入力の「範囲」を宣言することで、人間が思いつかなかったケースを自動生成して検証します。
- Python: Hypothesisを
pip install hypothesisで導入、@givenデコレータで書ける - JavaScript/TypeScript: fast-checkを
pnpm add -D fast-checkで導入、既存のJest/Vitestと組み合わせる - 適用優先度: 純粋関数 > 変換ロジック > パーサー > 状態機械の順で導入効果が高い
- CI統合:
max_examplesとseed固定で実用的な実行時間に抑えられる
AI開発ワークフロー全体の品質設計についてはAI生成コードのテスト戦略を、LLM出力の評価自動化についてはLLM出力の品質ゲート設計を参照してください。
