第5章: プロパティベーステスト — 6言語統合ガイド¶
1. はじめに¶
従来の Example-Based Testing は「特定の入力に対する特定の出力」を検証しますが、プロパティベーステスト(PBT)は「すべての入力に対して成り立つ性質」を検証します。テストデータを自動生成し、数百〜数千のランダムケースで性質を検証することで、開発者が思いつかないエッジケースを発見できます。
2. 共通の本質¶
PBT の基本構造¶
1. 性質(Property)を定義する
2. テストデータをランダムに生成する(Generator)
3. 性質が成り立つか検証する
4. 失敗時、最小の反例を見つける(Shrinking)
代表的な性質パターン¶
| パターン | 定義 | 例 |
|---|---|---|
| 冪等性(Idempotency) | f(f(x)) = f(x) |
sort のソート済み再ソート |
| 対合性(Involution) | f(f(x)) = x |
reverse の二重適用 |
| 不変量(Invariant) | 操作前後で保存される性質 | ソート後も要素数は同じ |
| 往復(Round-trip) | decode(encode(x)) = x |
シリアライズの往復 |
| モデルベース | 別の実装と同じ結果 | 自作 sort と標準 sort の比較 |
3. 言語別実装比較¶
3.1 PBT ライブラリ¶
| 言語 | ライブラリ | 特徴 |
|---|---|---|
| Clojure | test.check | Spec との統合、ジェネレータが豊富 |
| Scala | ScalaCheck | 型クラスベース、ScalaTest 統合 |
| Elixir | StreamData | Stream ベースの遅延生成 |
| F# | FsCheck | 属性ベース、[<Property>] で宣言的 |
| Haskell | QuickCheck | PBT の元祖、条件付きプロパティ |
| Rust | proptest | マクロベース、Strategy trait で拡張 |
3.2 冪等性の検証¶
「同じ操作を 2 回適用しても結果が変わらない」性質です。
Clojure: test.check
(require '[clojure.test.check :as tc]
'[clojure.test.check.generators :as gen]
'[clojure.test.check.properties :as prop])
(def prop-sort-idempotent
(prop/for-all [nums (gen/vector gen/small-integer)]
(= (sort nums) (sort (sort nums)))))
(tc/quick-check 100 prop-sort-idempotent)
Scala: ScalaCheck
import org.scalacheck.Prop.forAll
test("ソートは冪等である") {
forAll { (nums: List[Int]) =>
val sorted = nums.sorted
sorted shouldBe sorted.sorted
}
}
Haskell: QuickCheck
import Test.QuickCheck
prop_sortIdempotent :: [Int] -> Bool
prop_sortIdempotent xs = sort (sort xs) == sort xs
-- 実行
-- quickCheck prop_sortIdempotent
Rust: proptest
use proptest::prelude::*;
proptest! {
#[test]
fn sort_is_idempotent(mut xs: Vec<i32>) {
xs.sort();
let sorted_once = xs.clone();
xs.sort();
prop_assert_eq!(xs, sorted_once);
}
}
F#: FsCheck
[<Property>]
let ``ソートは冪等である`` (nums: int list) =
let sorted = List.sort nums
sorted = List.sort sorted
Elixir: StreamData
property "ソートは冪等である" do
check all nums <- list_of(integer()) do
sorted = Enum.sort(nums)
assert sorted == Enum.sort(sorted)
end
end
3.3 対合性と往復の検証¶
reverse の対合性(全言語比較)
;; Clojure
(def prop-reverse-involutory
(prop/for-all [xs (gen/vector gen/small-integer)]
(= xs (vec (reverse (reverse xs))))))
// Scala
forAll { (xs: List[Int]) =>
xs.reverse.reverse shouldBe xs
}
# Elixir
check all xs <- list_of(integer()) do
assert xs == xs |> Enum.reverse() |> Enum.reverse()
end
// F#
[<Property>]
let ``reverseの対合性`` (xs: int list) =
xs |> List.rev |> List.rev = xs
-- Haskell
prop_reverseInvolutory :: [Int] -> Bool
prop_reverseInvolutory xs = reverse (reverse xs) == xs
// Rust
proptest! {
#[test]
fn reverse_involutive(xs: Vec<i32>) {
let reversed: Vec<_> = xs.iter().rev().rev().cloned().collect();
prop_assert_eq!(&xs, &reversed);
}
}
3.4 ジェネレータの設計¶
| ジェネレータ | Clojure | Scala | Elixir | F# | Haskell | Rust |
|---|---|---|---|---|---|---|
| 整数 | gen/int |
arbitrary[Int] |
integer() |
型推論 | arbitrary |
型推論 |
| 文字列 | gen/string |
Gen.alphaStr |
string(:alpha) |
型推論 | arbitrary |
型推論 |
| リスト | gen/vector |
Gen.listOf |
list_of |
型推論 | listOf |
型推論 |
| カスタム型 | gen/fmap |
Gen.map |
gen all |
計算式 | Gen モナド |
Strategy trait |
| 範囲指定 | gen/choose |
Gen.choose |
integer(1..100) |
Gen.choose |
choose |
1..100i32 |
カスタムジェネレータの比較
;; Clojure: gen/fmap で変換
(def gen-person
(gen/fmap (fn [[name age]]
{:name name :age age})
(gen/tuple gen/string-alphanumeric
(gen/choose 0 150))))
// Scala: for 式で合成
val genPerson: Gen[Person] = for
name <- Gen.alphaStr
age <- Gen.choose(0, 150)
yield Person(name, age)
-- Haskell: Gen モナドで合成
genPerson :: Gen Person
genPerson = do
name <- arbitrary
age <- choose (0, 150)
return $ Person name age
// Rust: Strategy trait で合成
fn person_strategy() -> impl Strategy<Value = Person> {
("[a-zA-Z]+", 0..150u32).prop_map(|(name, age)| {
Person { name, age }
})
}
3.5 シュリンキング(最小反例の発見)¶
テストが失敗した場合、PBT ライブラリは自動的に入力を縮小し、最小の反例を見つけます。
| 言語 | シュリンク方式 | 特徴 |
|---|---|---|
| Clojure | ジェネレータ統合 | ジェネレータが縮小方法も知っている |
| Scala | Shrink 型クラス |
カスタムシュリンク定義可能 |
| Elixir | ジェネレータ統合 | StreamData が自動対応 |
| F# | Shrink 型 |
FsCheck が自動対応 |
| Haskell | Arbitrary 型クラスの shrink |
最も細かく制御可能 |
| Rust | ジェネレータ統合 | Strategy に組み込み |
4. 比較分析¶
4.1 PBT の表現力¶
| 基準 | 最も表現力が高い言語 | 理由 |
|---|---|---|
| ジェネレータ合成 | Haskell | モナド合成で自由に組み合わせ |
| Spec 統合 | Clojure | 仕様定義からテストデータを自動生成 |
| 宣言的記述 | F# | [<Property>] 属性で最も簡潔 |
| マクロの活用 | Rust | proptest! マクロで定型コードを削減 |
| テスト統合 | Scala | ScalaTest との seamless な統合 |
| ストリーム生成 | Elixir | Stream ベースで遅延評価 |
4.2 静的型付け vs 動的型付けの影響¶
静的型付け言語(Scala, F#, Haskell, Rust):
- 型情報からジェネレータを自動推論できる
arbitraryを書くだけで適切なジェネレータが選択される- カスタム型のジェネレータも型クラスで定義可能
動的型付け言語(Clojure, Elixir):
- ジェネレータを明示的に指定する必要がある
- Clojure は Spec との統合でこの弱点を克服
- より柔軟なジェネレータ合成が可能
4.3 Clojure Spec と PBT の統合¶
Clojure の最大の強みは、第 4 章で定義した Spec が PBT のジェネレータとして直接再利用できることです:
;; 第4章で定義した Spec
(s/def ::person (s/keys :req-un [::name ::age ::email]))
;; そのまま PBT のジェネレータとして使用
(def prop-person-roundtrip
(prop/for-all [person (s/gen ::person)]
(= person (deserialize (serialize person)))))
この統合は他の言語では実現できない、Clojure 固有の強みです。
5. 実践的な選択指針¶
| 要件 | 推奨言語 | 理由 |
|---|---|---|
| 仕様とテストの一体化 | Clojure | Spec が仕様・検証・テスト生成を統合 |
| 型駆動テスト生成 | Haskell | Arbitrary 型クラスで自動推論 |
| 既存テストへの段階的導入 | Scala | ScalaTest との統合が容易 |
| 宣言的テスト記述 | F# | [<Property>] 属性で最小限のボイラープレート |
| マクロによる簡潔さ | Rust | proptest! マクロが定型コードを削減 |
| ストリーム処理との統合 | Elixir | StreamData がデータ処理パイプラインと整合 |
6. まとめ¶
プロパティベーステストは、関数型プログラミングの数学的性質をテストに活かす手法です:
- 性質パターン: 冪等性・対合性・不変量・往復は言語を問わず共通
- ジェネレータ設計: 静的型は自動推論、動的型は明示指定が基本
- Spec 統合: Clojure の Spec → PBT パイプラインは他言語にない強み
- シュリンキング: すべてのライブラリが最小反例の自動発見を提供