プロパティベーステストと最小化(shrinking)
手で例を並べるテストでは抜ける入力を、性質の検証で機械に探させる。ランダム生成で大量の反例を見つけ、shrinking が最小の例まで縮めてバグの核心を一目で示します。
- 1.プロパティベーステストは具体例ではなく入力全体に成り立つべき性質(プロパティ)を書き、ランダム生成器が大量の入力で反証を試みる手法。例ベースの見落としを機械的に潰せる。
- 2.反例が見つかると shrinking が働き、性質を破る最小・最単純な入力まで自動で縮約する。巨大なランダム値ではなく核心の最小反例が報告されるため原因特定が速い。
- 3.再現性は乱数シードで担保し、生成器は型から自動導出するか手で組み立てる。QuickCheck 系の本質は generator・property・shrinker の3点セット。
例を並べるテストの限界
テストの基礎で扱う通常の単体テストは、入力と期待出力の組を人が1つずつ書く**例ベース(example-based)**です。add(2,3) が 5 になる、という具合に。しかし人が思いつく例は、自分が想定した範囲に偏ります。境界値、空入力、負数、極端に長い列、Unicode の合成文字といった「思いつかなかった入力」こそバグの巣であり、例ベースではそこに穴が残りやすい。
**プロパティベーステスト(property-based testing, PBT)**は発想を反転させます。個別の入力ごとの答えを書く代わりに、**入力全体に対して常に成り立つべき性質(プロパティ)**を1つ宣言し、その性質を破る入力をフレームワークに探させます。具体的な期待値は人が用意せず、forAll x. P(x)(任意の x について述語 P が真)という形を機械に検証させるのが核心です。
例ベース: reverse([1,2,3]) == [3,2,1] ← 1つの入力だけ確認
プロパティ: forAll xs. reverse(reverse(xs)) == xs ← あらゆる xs で確認
プロパティの作り方
PBT で最初に詰まるのは「どんな性質を書くか」です。期待出力を直書きできない以上、入力と出力の不変な関係を見つける必要があります。代表的なパターンを押さえると応用が利きます。
| パターン | 内容 | 例 |
|---|---|---|
| 往復(round-trip) | 符号化→復号で元に戻る | decode(encode(x)) == x |
| 不変条件(invariant) | 操作後も保たれる性質 | sort(xs) は要素数と多重集合が不変 |
| 冪等性(idempotence) | 2回適用しても変わらない | sort(sort(xs)) == sort(xs) |
| 参照実装(oracle) | 遅いが正しい実装と一致 | fastSort(xs) == naiveSort(xs) |
| 代数法則 | 結合・可換などの法則 | (a+b)+c == a+(b+c) |
往復性は直列化・圧縮・パーサで特に強力で、parse(print(x)) == x が壊れれば往復のどこかにバグがあると分かります。参照実装(テストオラクル)パターンは、最適化版を素朴版と突き合わせるもので、両者が同じ出力を返すことを性質にします。性質は「絶対的な正しさ」を一発で表現できなくても、複数の弱い性質を束ねることで実用上の網羅性に近づけます。
ランダム生成器(generator)
性質を確かめる入力はジェネレータが乱数から生成します。整数なら範囲を、リストなら長さと要素ジェネレータを、構造体なら各フィールドのジェネレータを合成して作ります。QuickCheck 系では型からジェネレータを自動導出する仕組み(Haskell の Arbitrary、Rust の proptest の Arbitrary 派生など)が用意され、独自型は小さなジェネレータを組み合わせて定義します。
genInt : 範囲内の整数を1つ生成
genList(genInt) : 長さを乱択し、その数だけ genInt を呼ぶ
genUser : genName と genAge を合成して User を組む
実務で効くのは分布の設計です。一様乱数だけでは境界(0、空リスト、最大値)が出にくいので、良いライブラリは境界値やコーナーケースの出現確率を上げる、あるいはサイズパラメータ(size)を徐々に大きくして小さな入力から試す、といった工夫をします。多数(既定で100回前後)の試行を回し、1つでも性質を破れば**反例(counterexample)**として停止します。
ランダムである以上、失敗の再現性が問題になります。PBT フレームワークは乱数シードを固定・記録し、同じシードからは同じ入力列を再生成できるようにします。失敗時はシードが報告されるので、それを指定すれば同じ反例を何度でも再現できます。シードを記録しないテストは、たまに落ちて再現できない不安定なテスト(フレーキー)になってしまいます。
反例の最小化(shrinking)
PBT の真価は最小化にあります。ランダム生成された反例は、たいてい巨大で雑然としています。たとえば「長さ 47 のリスト [8, -3, 991, 0, ...] でソートが壊れた」と言われても、何が引き金かは分かりません。そこでshrinkingが、性質を破ったまま入力をより小さく・単純に縮約していきます。
仕組みはこうです。ジェネレータには対になる**シュリンカ(shrinker)**があり、ある値から「それより単純な候補のリスト」を返します。整数 991 なら 0, 約半分, 990 のような候補、リストなら「要素を1つ削ったもの」「前半だけ」などです。フレームワークは反例に対してシュリンカを呼び、候補のうち性質をまだ破るものを見つけたらそれを新しい反例として採用し、これ以上縮められなくなるまで貪欲に繰り返します。
最初の反例: [8, -3, 991, 0, 5, 7, -100, ...] (長さ47)
↓ 要素を削る・値を0へ寄せる shrink を反復
最小反例: [1, 0] ← これでもう壊れる最小の形
結果として、人手のデバッグなしにバグを引き起こす最小・最単純な入力が手に入ります。多くの実装が探索する縮約は局所最小に落ちる貪欲法であり、真の大域最小を保証はしません。しかし「2要素のリスト」「整数 1 つ」まで落ちれば、原因の見当は劇的につけやすくなります。これはデバッグの delta-debugging(差分最小化)と同じ発想です。
古典的 QuickCheck では generator と shrinker を別々に定義するため、両者がずれて「縮約後の値が本来の制約を満たさない」事故が起きえます。Hypothesis(Python)や Hedgehog(Haskell)が採る統合型(integrated/internal)shrinkingは、生成に使った乱数列そのものを縮約し、同じジェネレータを再実行して値を作り直す方式です。これにより制約は自動的に維持され、shrinker を別途書く手間も消えます。
QuickCheck 系の内部動作
最初の QuickCheck(Claessen と Hughes, 2000, Haskell)以来の系譜は、概念的に3つの部品で説明できます。property(forAll で束ねた検証可能な述語)、generator(入力を作る)、shrinker(反例を縮める)です。実行ループは単純です。
for i in 1..N:
seed = next(rng)
input = generator.run(seed, size=growing)
if not property(input):
minimal = shrink(input, property) # 性質を破り続ける限り縮約
report(seed, minimal) # シードと最小反例を出力
stop
report("OK, passed N tests")
サイズ(size)を試行ごとに増やすのは、小さな入力で早くバグを出しつつ、後半で複雑な入力もカバーするためです。性質が全称量化(forAll)である以上、PBT は反証(バグの発見)には強い一方、有限回の試行で正しさの証明はできません。これは計算量の議論と同じく、テストが網羅ではなく標本である点に由来します。形式手法(モデル検査・定理証明)が全域を扱うのに対し、PBT は確率的標本で安価に高い被覆を狙う中間策です。
PBT の核は generator・property・shrinker の3点。ランダム生成は反例探索、shrinking は反例の最小化を担う。再現性は乱数シードで保証する。PBT は反証に強く、有限試行ゆえ正しさの証明にはならない点を例ベース・形式手法と対比して理解すること。
まとめ
プロパティベーステストは、個別の例ではなく入力全体に成り立つ性質を宣言し、ランダム生成器がそれを破る入力を機械的に探す手法です。往復・不変条件・冪等性・参照実装といったパターンで性質を組み立て、ジェネレータが多数の入力を投げ込みます。反例が出ればshrinkingが性質を破ったまま最小・最単純な形まで縮約し、デバッグの起点を一発で与えます。再現性は乱数シードで担保され、生成と縮約を統合する近代的実装は制約維持と省力化を両立します。反証に強く証明にはならないという性格を理解すれば、例ベーステストの穴を埋める強力な相棒になります。なお、生成器が不変な値を作り副作用を持たないことが、再現性と縮約の正しさを支えます。
プログラミング Article
プロパティベーステストと最小化(shrinking)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
テスト
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
反例が見つかると shrinking が働き、性質を破る最小・最単純な入力まで自動で縮約する。巨大なランダム値ではなく核心の最小反例が報告されるため原因特定が速い。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「テスト / プロパティベーステスト」に近いか確認する。
- 強みである「プロパティベーステストは具体例ではなく入力全体に成り立つべき性質(プロパティ)を書き、ランダム生成器が大量の入力で反証を試みる手法。例ベースの見落としを機械的に潰せる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。