Skip to content

Instantly share code, notes, and snippets.

@lagenorhynque
Last active January 30, 2026 00:28
Show Gist options
  • Select an option

  • Save lagenorhynque/bf99fc6fe23cc8a5a1b65f120fef622d to your computer and use it in GitHub Desktop.

Select an option

Save lagenorhynque/bf99fc6fe23cc8a5a1b65f120fef622d to your computer and use it in GitHub Desktop.
Property-Based Testing with test.check and clojure.spec: ClojureでPBTに(再)入門しよう
slides:
charset: utf-8
theme: night
highlight_theme: monokai-sublime
separator_vertical: ^\s*----\s*$
revealjs:
transition: convex
plugins:
- extra_css:
- https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css

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

ClojureでPBTに(再)入門しよう


x icon


  1. プロパティベーステストとは

  2. test.check + clojure.specの基本

  3. test.check + clojure.specの実践


1. プロパティベーステストとは


プロパティベーステスト
(property-based testing, PBT)

  • 入力と予想結果について具体例を挙げるテスト(example-based testing, EBT)に対して、
    「プロパティ」(任意の入力について成り立つ性質)を定義してランダム生成値で試すテスト

  • a.k.a. generative testing (生成的テスト)

  • 🐬<

    • 関数型言語の入門書で紹介されることが多い印象

    • 保証の度合いと実装コスト: EBT < PBT < 証明


PBTのためのライブラリ

  • 実践のためには専用のライブラリが必要

    • 標準的なジェネレーター(generator, arbitrary)

    • ジェネレーターを組み合わせるコンビネーター

    • 実行してエラー時の入力を収縮(shrink)する機構

  • 元祖といえるのがHaskellQuickCheck

    • 関数型言語を中心に多くの言語に移植されている
  • 🐬< 現在の仕事でJS/TSのfast-check、Java/Kotlinのjqwikをよく利用している


[参考] 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より


pbt book

🐬< 最近、社内勉強会として読書会を始めた💪


2. test.check + clojure.specの
基本


  • 準標準ライブラリ(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}

テストが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)

基本構文(マクロ)

  • プロパティ: 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)

ジェネレーターに対する主なコンビネーター

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 関数相当)

  • let (return, bind, fmap に対する糖衣構文)

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

見落とされがちな(?)この点が非常に強力😏


冒頭の例を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}

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})

⚠️ 利用上の主な注意点

  • clojure.spec.gen.alpha (sgen)とclojure.test.check.generators (gen)の関係
    • sgenからgenの大多数のオペレーターが使えるが遅延ロードされている
      • → src配下のgen参照を避けるとtest.checkはdev/test dependenciesに限定できる
  • 述語と同名のtest.checkジェネレーターがあっても完全に同じ実装とは限らない
  • s/and を利用する場合には標準の述語をベースに
    • 無条件でジェネレーターになるわけではない

3. test.check + clojure.specの
実践


『実践プロパティベーステスト』の例題/演習問題

;; 標準ライブラリ関数 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): 現在価値の計算

現在価値 = 将来価値 ( 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に挑戦し活用しよう!


Further Reading

#!/usr/bin/env bash
# pip install mkslides
open http://localhost:8000 \
&& mkslides serve *.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment