第5章: プロパティベーステスト¶
はじめに¶
従来の単体テストでは、特定の入力に対する期待される出力を検証します。一方、プロパティベーステストでは、すべての入力に対して成り立つべき「性質(プロパティ)」を定義し、ランダムに生成された多数のテストケースで検証します。
本章では、Clojure の test.check ライブラリを使ったプロパティベーステストの手法を学びます。
1. プロパティベーステストとは¶
従来のテストとの違い¶
;; 従来のテスト:特定の入力に対する出力を検証
(deftest test-reverse
(is (= "olleh" (reverse-string "hello")))
(is (= "" (reverse-string "")))
(is (= "a" (reverse-string "a"))))
;; プロパティベーステスト:性質を検証
(def prop-reverse-involutory
"文字列を2回反転すると元に戻る"
(prop/for-all [s gen/string]
(= s (reverse-string (reverse-string s)))))
プロパティベーステストの利点¶
- 網羅性: 手動では思いつかないエッジケースを発見
- ドキュメント性: コードの性質を明確に表現
- 回帰防止: リファクタリング時の安全網
- シュリンキング: 失敗時に最小の反例を提示
2. 基本的なジェネレータ¶
プリミティブジェネレータ¶
(require '[clojure.test.check.generators :as gen])
;; 整数
gen/small-integer ;; -200〜200程度の整数
gen/nat ;; 自然数(0以上)
gen/pos-int ;; 正の整数
gen/neg-int ;; 負の整数
;; 文字列
gen/string ;; 任意の文字列
gen/string-alphanumeric ;; 英数字のみ
;; その他
gen/boolean ;; true/false
gen/char ;; 文字
gen/keyword ;; キーワード
;; サンプル生成
(gen/sample gen/small-integer 5)
;; => (0 1 -2 1 4)
範囲と列挙¶
;; 範囲指定
(def age-gen (gen/choose 0 150))
;; 浮動小数点数
(def percentage-gen (gen/double* {:min 0.0 :max 1.0}))
;; 列挙型
(def membership-gen (gen/elements [:bronze :silver :gold :platinum]))
3. コレクションジェネレータ¶
ベクター・リスト¶
;; ベクター
(gen/vector gen/small-integer) ;; 任意長
(gen/vector gen/small-integer 1 10) ;; 1〜10要素
;; リスト
(gen/list gen/small-integer)
;; 非空コレクション
(gen/not-empty (gen/vector gen/small-integer))
マップとセット¶
;; マップ
(gen/map gen/string-alphanumeric gen/small-integer)
;; セット
(gen/set gen/small-integer)
;; 構造化されたマップ
(gen/hash-map
:name gen/string-alphanumeric
:age (gen/choose 0 150)
:active gen/boolean)
4. 複合ジェネレータ¶
ドメインモデルのジェネレータ¶
(def person-gen
"人物データのジェネレータ"
(gen/hash-map
:name (gen/such-that #(not (empty? %)) gen/string-alphanumeric)
:age (gen/choose 0 150)
:membership (gen/elements [:bronze :silver :gold :platinum])))
(def product-gen
"商品データのジェネレータ"
(gen/hash-map
:product-id (gen/fmap #(str "PROD-" (format "%05d" (mod % 100000))) gen/nat)
:name (gen/such-that #(not (empty? %)) gen/string-alphanumeric)
:price (gen/fmap #(+ 1 (mod % 10000)) gen/nat)
:quantity (gen/fmap #(+ 1 (mod % 100)) gen/nat)))
(gen/sample product-gen 3)
;; => ({:product-id "PROD-00000", :name "a", :price 1, :quantity 1}
;; {:product-id "PROD-00001", :name "Ab", :price 2, :quantity 2}
;; {:product-id "PROD-00003", :name "x2Y", :price 4, :quantity 3})
5. ジェネレータの変換¶
fmap: 値の変換¶
;; 生成された値を変換
(def uppercase-string-gen
(gen/fmap clojure.string/upper-case gen/string-alphanumeric))
;; フォーマットされたID
(def order-id-gen
(gen/fmap #(str "ORD-" (format "%08d" %))
(gen/choose 0 99999999)))
bind: 依存関係のある生成¶
;; 生成された値に基づいて別のジェネレータを選択
(def non-empty-subset-gen
(gen/bind (gen/not-empty (gen/vector gen/small-integer))
(fn [v]
(gen/fmap #(take % v)
(gen/choose 1 (count v))))))
such-that: フィルタリング¶
;; 条件を満たす値のみを生成
(def positive-even-gen
(gen/such-that #(and (pos? %) (even? %)) gen/small-integer 100))
6. プロパティの定義¶
基本的なプロパティ¶
(require '[clojure.test.check.properties :as prop])
;; 文字列反転の性質
(def prop-reverse-involutory
"文字列を2回反転すると元に戻る"
(prop/for-all [s gen/string]
(= s (reverse-string (reverse-string s)))))
(def prop-reverse-length-preserved
"反転しても長さは変わらない"
(prop/for-all [s gen/string]
(= (count s) (count (reverse-string s)))))
ソートの性質¶
(def prop-sort-idempotent
"ソートは冪等(2回ソートしても結果は同じ)"
(prop/for-all [nums (gen/vector gen/small-integer)]
(= (sort-numbers nums) (sort-numbers (sort-numbers nums)))))
(def prop-sort-preserves-elements
"ソートは要素を保存する(追加も削除もしない)"
(prop/for-all [nums (gen/vector gen/small-integer)]
(= (frequencies nums) (frequencies (sort-numbers nums)))))
(def prop-sort-ordered
"ソート結果は昇順に並ぶ"
(prop/for-all [nums (gen/vector gen/small-integer)]
(let [sorted (sort-numbers nums)]
(or (empty? sorted)
(apply <= sorted)))))
ビジネスロジックの性質¶
(def prop-discount-bounds
"割引後の価格は0以上、元の価格以下"
(prop/for-all [price (gen/fmap #(Math/abs %) gen/small-integer)
rate (gen/double* {:min 0.0 :max 1.0})]
(let [discounted (calculate-discount price rate)]
(and (>= discounted 0)
(<= discounted price)))))
7. プロパティの実行¶
quick-check¶
(require '[clojure.test.check :as tc])
;; 100回テスト
(tc/quick-check 100 prop-reverse-involutory)
;; => {:result true, :pass? true, :num-tests 100, :time-elapsed-ms 15, ...}
;; 1000回テスト
(tc/quick-check 1000 prop-sort-ordered)
失敗時の出力¶
;; バグのある関数
(defn buggy-abs [n]
(if (neg? n) (- n) n))
(def prop-abs-positive
(prop/for-all [n gen/small-integer]
(>= (buggy-abs n) 0)))
(tc/quick-check 10000 prop-abs-positive)
;; Integer/MIN_VALUE で失敗する場合、シュリンキングで最小反例を提示
8. シュリンキング¶
テストが失敗すると、test.check は自動的にシュリンキングを行い、同じ失敗を引き起こす最小の入力を探します。
;; 例:リストの長さが5未満というバグのあるプロパティ
(def prop-buggy
(prop/for-all [v (gen/vector gen/small-integer)]
(< (count v) 5)))
(tc/quick-check 100 prop-buggy)
;; => {:result false,
;; :pass? false,
;; :shrunk {:total-nodes-visited 7,
;; :smallest [[0 0 0 0 0]]}, ;; 最小の反例
;; ...}
シュリンキングにより、「長さ100のリストで失敗」ではなく「長さ5のリストで失敗」という最小の反例が得られます。
9. ラウンドトリッププロパティ¶
エンコード/デコードの可逆性は典型的なプロパティです。
(defn encode-run-length [s]
(->> s
(partition-by identity)
(map (fn [group] [(first group) (count group)]))
(into [])))
(defn decode-run-length [encoded]
(->> encoded
(mapcat (fn [[ch n]] (repeat n ch)))
(apply str)))
(def prop-run-length-roundtrip
"ランレングス符号化は可逆"
(prop/for-all [s (gen/fmap #(apply str %) (gen/vector gen/char-alpha))]
(= s (decode-run-length (encode-run-length s)))))
10. 実践的なパターン¶
代数的性質¶
;; 結合律
(def prop-addition-associative
(prop/for-all [a gen/small-integer
b gen/small-integer
c gen/small-integer]
(= (+ (+ a b) c) (+ a (+ b c)))))
;; 交換律
(def prop-addition-commutative
(prop/for-all [a gen/small-integer
b gen/small-integer]
(= (+ a b) (+ b a))))
オラクルテスト¶
既知の正しい実装と比較します。
(def prop-my-sort-matches-builtin
"自作ソートは組み込みソートと同じ結果"
(prop/for-all [nums (gen/vector gen/small-integer)]
(= (my-sort nums) (sort nums))))
不変条件¶
(def prop-order-total-positive
"注文合計は常に正"
(prop/for-all [order order-gen]
(pos? (calculate-order-total order))))
まとめ¶
本章では、プロパティベーステストについて学びました:
- ジェネレータ: テストデータの自動生成
- プリミティブ: 整数、文字列、ブール値など
- コレクション: ベクター、マップ、セット
- 変換: fmap, bind, such-that による加工
- プロパティ: すべての入力で成り立つべき性質
- シュリンキング: 失敗時の最小反例探索
- パターン: ラウンドトリップ、代数的性質、オラクルテスト
プロパティベーステストは、従来のテストを補完し、より堅牢なソフトウェアを実現します。
参考コード¶
本章のコード例は以下のファイルで確認できます:
- ソースコード:
apps/clojure/part2/src/property_based_testing.clj - テストコード:
apps/clojure/part2/spec/property_based_testing_spec.clj
次章予告¶
次章では、テスト駆動開発と関数型プログラミングについて学びます。Red-Green-Refactor サイクルを関数型スタイルで実践する方法を探ります。