- 株式会社スマートラウンドのシニアエンジニア
- スタートアップと投資家のやり取りを効率化するデータ管理プラットフォームを開発している
- 技術スタック: Kotlin/Ktor & TypeScript/Vue.js
- Server-Side Kotlin Meetupの運営にも協力
- スタートアップと投資家のやり取りを効率化するデータ管理プラットフォームを開発している
- 関数型プログラミング(言語)とLispの熱烈な愛好者
- 関数型まつり2026の運営スタッフ(座長のひとり)
-
プロパティベーステストとは
-
test.check + clojure.specの基本
-
test.check + clojure.specの実践
-
入力と予想結果について具体例を挙げるテスト(example-based testing, EBT)に対して、
「プロパティ」(任意の入力について成り立つ性質)を定義してランダム生成値で試すテスト -
a.k.a. generative testing (生成的テスト)
-
🐬<
-
関数型言語の入門書で紹介されることが多い印象
-
保証の度合いと実装コスト: EBT < PBT < 証明
-
-
実践のためには専用のライブラリが必要
-
標準的なジェネレーター(generator, arbitrary)
-
ジェネレーターを組み合わせるコンビネーター
-
実行してエラー時の入力を収縮(shrink)する機構
-
-
元祖といえるのがHaskellのQuickCheck
- 関数型言語を中心に多くの言語に移植されている
-
🐬< 現在の仕事でJS/TSのfast-check、Java/Kotlinのjqwikをよく利用している
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より
🐬< 最近、社内勉強会として読書会を始めた💪
-
準標準ライブラリ(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}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)-
プロパティ:
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}-
数値:
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})- 選択:
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)-
such-that(filter関数相当) -
fmap(map関数相当)- cf. Haskellの
Functor型クラス
- cf. Haskellの
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)-
return,bind(mapcat関数相当)- cf. Haskellの
Monad型クラス
- cf. Haskellの
-
let(return,bind,fmapに対する糖衣構文)- cf. Haskellの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})マクロ展開すると 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__auto__)
val__6616__auto__
(clojure.test.check.generators/return val__6616__auto__)))))))))-
標準ライブラリ: Clojure 1.9 (2017年12月)〜
- ただし、2026年1月現在も alpha 😂
-
述語(predicate)ベースの仕様記述ライブラリ
公式ドキュメントのclojure.specのRationaleでは
Writing a spec should enable automatic:
- Validation
- Error reporting
- Destructuring
- Instrumentation
- Test-data generation
- Generative test generation
見落とされがちな(?)この点が非常に強力😏
(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};; 標準ライブラリの述語
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})- clojure.spec.gen.alpha (sgen)とclojure.test.check.generators (gen)の関係
- sgenからgenの大多数のオペレーターが使えるが遅延ロードされている
- → src配下のgen参照を避けるとtest.checkはdev/test dependenciesに限定できる
- sgenからgenの大多数のオペレーターが使えるが遅延ロードされている
- 述語と同名のtest.checkジェネレーターがあっても完全に同じ実装とは限らない
- sgenのgen-builtinsで対応関係が確認できる
s/andを利用する場合には標準の述語をベースに- 無条件でジェネレーターになるわけではない
;; 標準ライブラリ関数 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-with-proper-erlang-and-elixir
🐬が実務で書いたPBTの例(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 ; 将来価値 100万円
0.05 ; 割引率 5%
5) ; 年数 5年
783526.16M ; => 現在価値 約78万円※ 実際のスタック: TypeScript + fast-check, Kotlin + jqwik
(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))))🐬が実務で書いた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(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)))))-
😆 PBTという手法の良さ
- 簡潔なテストコードで膨大なパターンを試せる
- 想定外の動作を検出できる(かもしれない)
-
🥹 PBT実践における困難
- 意味のあるプロパティの発見
- 安定的かつ効率的なジェネレーターの実装
Clojureでも(他言語でも)ぜひPBTに挑戦し活用しよう!

