Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Property-Based Testing with test.check and cloj...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

Property-Based Testing with test.check and clojure.spec

Clojureでプロパティベーステストに(再)入門しよう!

Avatar for Kent OHASHI

Kent OHASHI

January 29, 2026
Tweet

More Decks by Kent OHASHI

Other Decks in Programming

Transcript

  1. のシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js の運営にも協力 関数型プログラミング( 言語)

    とLisp の熱烈な愛好者 ( 特に) と がエレガントで好き の運営スタッフ( 座長のひとり) lagénorhynque 🐬カマイルカ 株式会社スマートラウンド Server-Side Kotlin Meetup Clojure Haskell 関数型まつり2026 2
  2. プロパティベーステスト (property-based testing, PBT) 入力と予想結果について具体例を挙げるテスト (example-based testing, EBT) に対して、 「プロパティ」(

    任意の入力について成り立つ性質) を定義してランダム生成値で試すテスト a.k.a. generative testing ( 生成的テスト) 🐬< 関数型言語の入門書で紹介されることが多い印象 保証の度合いと実装コスト: EBT < PBT < 証明 5
  3. [ 参考] QuickCheck のテストコードと実行結果の例 ※ 公式ドキュメントの より import Test.QuickCheck --

    reverse 関数のプロパティ: 任意のリストを2 回reverse すると元に戻る prop_reverse :: [Int] -> Bool prop_reverse xs = reverse (reverse xs) == xs -- ghci (Haskell REPL) -- quickCheck: プロパティをテストして結果を表示する関数 >>> quickCheck prop_reverse +++ OK, passed 100 tests. Test.QuickCheck 7
  4. 準標準ライブラリ( ) のひとつ QuickCheck のClojure 版 test.check Clojure contrib (require

    '[clojure.test.check.generators :as gen] '[clojure.test.check.properties :as prop]) user> (clojure.test.check/quick-check 100 (prop/for-all [xs (gen/list gen/large-integer)] (= xs (->> xs reverse reverse)))) {:result true, :pass? true, ; テストの成否(pass) :num-tests 100, ; 試行回数 :time-elapsed-ms 13, :seed 1769528527433} 10
  5. テストがfail したときの結果データ user> (clojure.test.check/quick-check 100 (prop/for-all [xs (gen/list gen/large-integer)] ;;

    2 回目のreverse を敢えてコメントアウト (= xs (->> xs reverse #_reverse)))) {:shrunk ; 収縮(shrink) された結果 {:total-nodes-visited 7, :depth 1, :pass? false, :result false, :result-data nil, :time-shrinking-ms 6, :smallest [(0 1)]}, ; 単純化されたfail する入力値 :failed-after-ms 2, :num-tests 4, ; 試行回数 :seed 1769529199754, :fail [(-1 1)], ; fail 時の実際の入力値 :result false, :result-data nil, :failing-size 3, ; fail 時のsize 値(0, 1, 2, 3 で4 回目) :pass? false} ; テストの成否(fail) 11
  6. 基本構文( マクロ) プロパティ: for-all clojure.test 連携: defspec user> (tc/defspec reverse-test

    (prop/for-all [xs (gen/list gen/large-integer)] (= xs (->> xs reverse reverse)))) #'user/reverse-test user> (clojure.test/run-test reverse-test) Testing user {:result true, :num-tests 100, :seed 1769532780193, :time-elapsed-ms 15, :test-var "reverse-test"} Ran 1 tests containing 1 assertions. 0 failures, 0 errors. {:test 1, :pass 1, :fail 0, :error 0, :type :summary} 12
  7. 標準提供の主なジェネレーター 数値: small-integer, large-integer, double 文字: char, char-alphanumeric, char-ascii 文字列:

    string, string-alphanumeric コレクション: list, vector, tuple, set user> (gen/sample (gen/vector gen/double) 5) ([] [] [] [3.25 -0.75] [0.625 2.0 2.0]) user> (gen/sample (gen/tuple gen/string gen/small-integer) 5) (["" 0] ["" 0] ["\"" 2] ["" 1] ["" 3]) user> (gen/sample (gen/set gen/char-ascii) 5) (#{} #{\%} #{\space \o} #{\G} #{\h \m \R}) 13
  8. 選択: choose, elements, one-of, frequency user> (gen/sample (gen/choose 0 100))

    (66 99 38 63 86 64 34 13 74 87) user> (gen/sample (gen/elements #{:a :b :c :d})) (:b :d :b :a :a :b :a :d :a :d) user> (gen/sample (gen/one-of [gen/char gen/string])) (\Ê \h "¬¿" "}" "C4g%" "" \ú "½" "" ")ÏÚ¼Ub") user> (gen/sample (gen/frequency [[1 gen/large-integer] [3 gen/double]])) (0.5 1.0 -2.0 -2.0 1 -1.03125 0 -0.5625 -1.0546875 -1) 14
  9. ジェネレーターに対する主なコンビネーター such-that (filter 関数相当) fmap (map 関数相当) cf. Haskell のFunctor

    型クラス user> (gen/sample (gen/such-that seq gen/string-alphanumeric)) ("4" "o" "NVV" "T3" "YfqJ" "JH87x" "B4496" "6PZ" "1" "4rsOz") user> (gen/sample (gen/fmap #(* % %) gen/large-integer)) (0 0 1 9 16 1 0 169 1 49) 15
  10. return, bind (mapcat 関数相当) cf. Haskell の let (return, bind,

    fmap に対する糖衣構文) cf. Haskell の Monad 型クラス do 記法 user> (gen/sample (gen/let [x gen/small-integer y gen/large-integer] (gen/elements [x y]))) (0 0 1 -1 -2 4 -1 0 -4 7) user> (gen/sample (gen/let [x gen/small-integer y gen/large-integer] {:x x :y y}) 5) ({:x 0, :y -1} {:x -1, :y 0} {:x 1, :y -1} {:x 1, :y -1} {:x 1, :y 6}) 16
  11. マクロ展開すると bind ( と return) の連鎖になる user> (clojure.walk/macroexpand-all '(gen/let [x

    gen/small-integer y gen/large-integer] (gen/elements [x y]))) (clojure.test.check.generators/bind gen/small-integer (fn* ([x] (clojure.test.check.generators/bind gen/large-integer (fn* ([y] (let* [val__6616__auto__ (do (gen/elements [x y]))] (if ; ボディがジェネレーターでなければreturn でジェネレーターに (clojure.test.check.generators/generator? val__6616__aut val__6616__auto__ (clojure.test.check.generators/return val__6616__auto__) 17
  12. 標準ライブラリ: (2017 年12 月) 〜 ただし、2026 年1 月現在も alpha 😂

    述語(predicate) ベースの仕様記述ライブラリ 一種のcontract system cf. の clojure.spec Clojure 1.9 Racket contract 18
  13. 公式ドキュメントの では 見落とされがちな(?) この点が非常に強力😏 clojure.spec のRationale Writing a spec should

    enable automatic: Validation Error reporting Destructuring Instrumentation Test-data generation Generative test generation 19
  14. 冒頭の例をclojure.spec によるジェネレーター実装に 書き換えると (require '[clojure.spec.alpha :as s] '[clojure.spec.gen.alpha :as sgen]

    '[clojure.test.check.properties :as prop]) user> (clojure.test.check/quick-check 100 (prop/for-all [xs (s/gen (s/coll-of int? :kind list?))] (= xs (->> xs reverse reverse)))) {:result true, :pass? true, :num-tests 100, :time-elapsed-ms 13, :seed 1769528682166} 20
  15. clojure.spec のspec → test.check のジェネレーター ;; 標準ライブラリの述語 user> (sgen/sample (s/gen

    string?)) ("" "t" "4" "" "Tk6" "a21" "Lj" "" "iq7m" "") ;; セット user> (sgen/sample (s/gen #{:α :β :γ :δ}) 5) (:β :α :β :α :β) ;; シーケンスのspec user> (sgen/sample (s/gen (s/cat :s string? :n int?)) 5) (("" 0) ("O" -1) ("P9" -1) ("" 1) ("C2n" 0)) ;; ( エンティティとしての) マップのspec user> (s/def ::foo (s/and string? #(<= (count %) 10))) :user/foo user> (s/def ::bar nat-int?) :user/bar user> (sgen/sample (s/gen (s/keys :req-un [::foo ::bar])) 3) ({:foo "", :bar 1} {:foo "F", :bar 0} {:foo "6", :bar 0}) 21
  16. ⚠️ 利用上の主な注意点 (sgen) と (gen) の関係 sgen からgen の大多数のオペレーターが使えるが 遅延ロードされている

    → src 配下のgen 参照を避けるとtest.check は dev/test dependencies に限定できる 述語と同名のtest.check ジェネレーターがあっても 完全に同じ実装とは限らない sgen の で対応関係が確認できる s/and を利用する場合には標準の述語をベースに 無条件でジェネレーターになるわけではない clojure.spec.gen.alpha clojure.test.check.generators gen-builtins 22
  17. 『実践プロパティベーステスト』の例題/ 演習問題 cf. 🐬のリポジトリ: ;; 標準ライブラリ関数 range に関するプロパティのテスト (tc/defspec range-test

    1000 (prop/for-all [start (s/gen int?) len (s/gen (s/and nat-int? #(<= % 10000)))] (let [coll (range start (+ start len))] (and (= len ; 長さは想定通りか (count coll)) (increments? coll))))) ; 要素は1 ずつ増えているか ;; increments? ( テスト用のヘルパー関数) の実装は省略 lagenorhynque/property-based-testing-with- proper-erlang-and-elixir 24
  18. 🐬が実務で書いたPBT の例(1): の計算 現在価値= 将来価値 (1 + 割引率) 年数 ※

    実際のスタック: TypeScript + , Kotlin + 現在価値 ;; テスト対象: 現在価値の計算関数 (defn present-value [^BigDecimal future-value rate years] (.divide future-value (bigdec (math/pow (+ 1 rate) years)) 2 RoundingMode/DOWN)) user> (present-value 1000000M ; 将来価値 100 万円 0.05 ; 割引率 5% 5) ; 年数 5 年 783526.16M ; => 現在価値 約78 万円 fast-check jqwik 25
  19. (tc/defspec present-value-rate-zero-test 1000 (prop/for-all [future-value (sgen/fmap bigdec (s/gen nat-int?)) years

    (s/gen nat-int?)] ;; 割引率が0% のとき、年数にかかわらず現在価値は将来価値に一致する (= future-value (present-value future-value 0 years)))) (tc/defspec present-value-years-zero-test 1000 (prop/for-all [future-value (sgen/fmap bigdec (s/gen nat-int?)) rate (s/gen (s/and double? #(<= 0 % 1)))] ;; 年数が0 年のとき、割引率にかかわらず現在価値は将来価値に一致する (= future-value (present-value future-value rate 0)))) 26
  20. 🐬が実務で書いたPBT の例(2): の形式 証券コード (def ^:private allowed-letters ;; B, E,

    I, O, Q, V, Z は除外文字 "(?![BEIOQVZ])[A-Z]") (def security-code-regex " 証券コードの仕様: - 1300 〜9999 の範囲の4 桁の数字 - 2 桁目または4 桁目に英大文字( 除外文字を除く) が入ることがある" (re-pattern (str \^ "(?:1(?:[3-9]|" allowed-letters ")" "|[2-9](?:[0-9]|" allowed-letters "))" "[0-9](?:[0-9]|" allowed-letters ")" \$))) user> (re-matches security-code-regex "130A") "130A" user> (re-matches security-code-regex "130B") nil 27
  21. (def ^:private security-code-like-gen (let [num-or-letter-gen ; 数字またはA-Z の文字 (fn [num]

    (sgen/one-of [(sgen/return num) (s/gen (s/and char? #(<= (int \A) (int %) (int \Z))))]))] (gen/let [[first second third fourth] ; 1300-9999 の4 桁 (sgen/fmap str (s/gen (s/int-in 1300 (inc 9999)))) second' (num-or-letter-gen second) fourth' (num-or-letter-gen fourth)] (str first second' third fourth')))) (tc/defspec security-code-regex-test 1000 (prop/for-all [code security-code-like-gen] ;; 証券コードらしい形式の文字列は除外文字を含まなければマッチする (= (nil? (re-find #"[BEIOQVZ]" code)) (some? (re-matches security-code-regex code))))) 28
  22. おわりに 😆 PBT という手法の良さ 簡潔なテストコードで膨大なパターンを試せる 想定外の動作を検出できる( かもしれない) 🥹 PBT 実践における困難

    意味のあるプロパティの発見 安定的かつ効率的なジェネレーターの実装 Clojure でも( 他言語でも) ぜひPBT に挑戦し活用しよう! 29
  23. Further Reading cf. : 🐬によるClojure 実装 clojure/test.check: QuickCheck for Clojure

    Introduction to test.check Clojure - test.check A Practical Guide to test.check QuickCheck: Automatic testing of Haskell programs Clojure - spec Guide 『実践プロパティベーステスト ― PropEr と Erlang/Elixir ではじめよう』 lagenorhynque/property-based-testing-with- proper-erlang-and-elixir 30