-- Views
January 29, 26
スライド概要
Clojureでプロパティベーステストに(再)入門しよう!
「楽しく楽にcoolにsmartに」を理想とするprogrammer/philosopher/liberalist/realist。 好きな言語はClojure, Haskell, Elixir, English, français, русский。 読書、プログラミング、語学、法学、数学が大好き! イルカと海も大好き🐬
Property-Based Testing with test.check and clojure.spec で に 再 入門しよう Clojure PBT ( ) #lispmeetup 1
🐬カマイルカ lagénorhynque 株式会社スマートラウンドのシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js Server-Side Kotlin Meetupの運営にも協力 関数型プログラミング(言語)とLispの熱烈な愛好者 (特に) ClojureとHaskellがエレガントで好き 関数型まつり2026の運営スタッフ(座長のひとり) 2
プロパティベーステストとは の基本 の実践 1. 2. test.check + clojure.spec 3. test.check + clojure.spec 3
1. プロパティベーステストとは 4
プロパティベーステスト (property-based testing, PBT) 入力と予想結果について具体例を挙げるテスト (example-based testing, EBT)に対して、 「プロパティ」(任意の入力について成り立つ性質) を定義してランダム生成値で試すテスト a.k.a. generative testing (生成的テスト) 🐬< 関数型言語の入門書で紹介されることが多い印象 保証の度合いと実装コスト: EBT < PBT < 証明 5
のためのライブラリ 実践のためには専用のライブラリが必要 標準的なジェネレーター(generator, arbitrary) ジェネレーターを組み合わせるコンビネーター 実行してエラー時の入力を収縮(shrink)する機構 元祖といえるのがHaskellのQuickCheck 関数型言語を中心に多くの言語に移植されている 🐬< 現在の仕事でJS/TSのfast-check、Java/Kotlinの jqwikをよく利用している PBT 6
参考] 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
貴重な解説書として『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』 🐬< 最近、社内勉強会として読書会を始めた💪 8
の 2. test.check + clojure.spec 基本 9
test.check
準標準ライブラリ(Clojure contrib)のひとつ
QuickCheckのClojure版
(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
テストが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)]}, ;
:failed-after-ms 2,
:num-tests 4,
;
:seed 1769529199754,
:fail [(-1 1)],
; fail
:result false,
:result-data nil,
:failing-size 3,
; fail
:pass? false}
;
単純化されたfailする入力値
試行回数
時の実際の入力値
時のsize値(0, 1, 2, 3で4回目)
テストの成否(fail)
11
基本構文(マクロ)
プロパティ: 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
標準提供の主なジェネレーター
数値: 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
選択: 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
ジェネレーターに対する主なコンビネーター 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
関数相当)
cf. HaskellのMonad 型クラス
let (return, bind, fmap に対する糖衣構文)
cf. Haskellのdo記法
return, bind (mapcat
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
マクロ展開すると 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
clojure.spec 標準ライブラリ: Clojure 1.9 (2017年12月)〜 ただし、2026年1月現在も alpha 😂 述語(predicate)ベースの仕様記述ライブラリ 一種のcontract system cf. Racketのcontract 18
公式ドキュメントのclojure.specのRationaleでは Writing a spec should enable automatic: Validation Error reporting Destructuring Instrumentation Test-data generation Generative test generation 見落とされがちな(?)この点が非常に強力😏 19
冒頭の例を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
の
のジェネレーター
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
⚠️ 利用上の主な注意点 clojure.spec.gen.alpha (sgen)と clojure.test.check.generators (gen)の関係 sgenからgenの大多数のオペレーターが使えるが 遅延ロードされている → src配下のgen参照を避けるとtest.checkは dev/test dependenciesに限定できる 述語と同名のtest.checkジェネレーターがあっても 完全に同じ実装とは限らない sgenのgen-builtinsで対応関係が確認できる s/and を利用する場合には標準の述語をベースに 無条件でジェネレーターになるわけではない 22
の 3. test.check + clojure.spec 実践 23
『実践プロパティベーステスト』の例題/演習問題 標準ライブラリ関数 に関するプロパティのテスト ;; 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? (テスト用のヘルパー関数)の実装は省略 cf. 🐬のリポジトリ: lagenorhynque/property-based-testing-withproper-erlang-and-elixir 24
🐬が実務で書いたPBTの例(1): 現在価値の計算 将来価値 現在価値 = ( 1 + 割引率 ) 年数 テスト対象 現在価値の計算関数 ;; : (defn present-value [^BigDecimal future-value rate years] (.divide future-value (bigdec (math/pow (+ 1 rate) years)) 2 RoundingMode/DOWN)) user> (present-value 1000000M 0.05 5) 783526.16M ※ 将来価値 100万円 割引率 5% 年数 5年 現在価値 約78万円 ; ; ; ; => 実際のスタック: TypeScript + fast-check, Kotlin + jqwik 25
(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
🐬が実務で書いた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
(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
おわりに 😆 PBTという手法の良さ 簡潔なテストコードで膨大なパターンを試せる 想定外の動作を検出できる(かもしれない) 🥹 PBT実践における困難 意味のあるプロパティの発見 安定的かつ効率的なジェネレーターの実装 Clojureでも(他言語でも)ぜひPBTに挑戦し活用しよう! 29
Further Reading clojure/test.check: QuickCheck for Clojure Introduction to test.check Clojure - test.check A Practical Guide to test.check cf. QuickCheck: Automatic testing of Haskell programs Clojure - spec Guide ― PropEr Erlang/Elixir lagenorhynque/property-based-testing-withproper-erlang-and-elixir: Clojure 『実践プロパティベーステスト と ではじめよう』 🐬による 実装 30