传统单元测试验证「几个手写的示例」,而生成式测试(Property-Based Testing)验证的是程序的数学性质:输入由生成器随机产生,断言「对所有满足条件的输入,输出都满足某个性质」。Clojure 的 test.check 源自 Haskell QuickCheck 思想,配合 clojure.spec 可以自动从 spec 派生生成器,是发现边界缺陷、回归漏洞的最强工具之一。
如果你还不熟悉 spec 的定义与校验,建议先阅读 Clojure spec 与测试。
1. 属性驱动测试思想
1.1 示例测试 vs 属性测试
;; 示例测试:验证固定输入
(deftest reverse-test
(is (= [3 2 1] (reverse [1 2 3])))
(is (= [] (reverse [])))
(is (= [5] (reverse [5]))))
示例测试的盲区:你永远猜不到那个会出 bug 的特殊输入。属性测试改为验证不变量:
(defspec reverse-involution
100 ; 运行 100 次随机输入
(prop/for-all [v (gen/vector gen/int)]
(= v (reverse (reverse v))))) ; 两次反转等于原序列
prop/for-all 声明「对任意 gen/vector gen/int 生成的向量 v,性质成立」。如果失败,test.check 会找到最小的反例并报告。
1.2 哪些属性值得测试
| 属性类型 | 例子 |
|---|---|
| 不变量 | 排序后长度不变;过滤后所有元素满足谓词 |
| 幂等性 | (dedupe (dedupe xs)) = (dedupe xs) |
| 逆运算 | reverse ∘ reverse = id;编码/解码互逆 |
| 同态性 | 映射到和 = 和映射到(对纯映射函数) |
| 与模型对照 | 新实现 vs 简单参考实现输出一致 |
| 吸收性 | 常量参与不影响结果 |
一个好例子——排序函数的输出有序且是输入的排列:
(require '[clojure.string :as str]
'[clojure.test.check :as tc]
'[clojure.test.check.clojure-test :refer [defspec]]
'[clojure.test.check.generators :as gen]
'[clojure.test.check.properties :as prop])
(defspec sort-properties
(prop/for-all [v (gen/vector gen/int)]
(and (= (count v) (count (sort v))) ; 长度不变
(apply <= (sort v)) ; 升序
(= (frequencies v) (frequencies (sort v))) ; 是排列
(= (set v) (set (sort v))))))
2. test.check 基础
2.1 defspec 与 prop/for-all
(ns myapp.sort-test
(:require [clojure.test :refer [deftest is]]
[clojure.test.check :as tc]
[clojure.test.check.clojure-test :refer [defspec]]
[clojure.test.check.generators :as gen]
[clojure.test.check.properties :as prop]))
;; defspec 生成一个 clojure.test 测试
(defspec sort-elements-are-unique-and-ordered
100 ; 次数
(prop/for-all [xs (gen/vector gen/int)]
(let [s (sort xs)]
(and (= (count xs) (count s))
(apply <= s)))))
;; 手动运行(不注册进 clojure.test)
(def result (tc/quick-check 100 (prop/for-all [n gen/nat] (>= n 0))))
;; => {:result true, :num-tests 100, :seed 1727312345, ...}
2.2 运行测试
# 作为普通 clojure.test 运行
clojure -M:test -m cognitect.test-runner
;; 单独查看
(clojure.test/run-tests 'myapp.sort-test)
defspec 返回的就是一个 clojure.test/deftest 风格的 var,可以融入既有测试体系。
3. 生成器组合
生成器(Generator)是产生随机值的可组合工厂。理解生成器组合是写好属性测试的核心。
3.1 基础生成器速查
gen/int ; 任意整数
gen/nat ; 非负整数
gen/pos-int ; 正整数
gen/large-integer ; 大整数(含超大值)
gen/boolean ; true / false
gen/keyword ; 任意关键字
gen/string ; 任意字符串
gen/string-alphanumeric ; 字母数字字符串
gen/char ; 任意字符
gen/vector gen/int ; 整数向量
gen/list gen/int ; 整数列表
gen/map gen/keyword gen/int ; 关键字→整数映射
gen/one-of [gen/int gen/string] ; 随机选一个生成器
gen/elements [1 2 3] ; 从给定集合选元素
gen/return 42 ; 常量生成器
gen/choose 1 10 ; 区间内整数
gen/double ; 浮点数
gen/uuid ; UUID
3.2 fmap:变换生成值
;; 生成合法的邮箱格式
(def gen-email
(gen/fmap (fn [[user domain]]
(str user "@" domain))
(gen/tuple (gen/string-alphanumeric)
(gen/string-alphanumeric))))
;; 生成带范围的年龄
(def gen-age
(gen/fmap #(mod % 100) gen/nat))
3.3 bind:依赖生成
gen/bind 让第二个生成器依赖第一个的值(monad 风格),用于生成「相关字段」:
;; 生成 start <= end 的区间
(def gen-interval
(gen/bind gen/int
(fn [start]
(gen/fmap (fn [end] [start end])
(gen/choose start (inc start 1000))))))
;; 用 bind 生成「长度与内容相关」的嵌套结构
(def gen-valid-password
(gen/bind (gen/choose 8 20)
(fn [len]
(gen/fmap #(apply str %)
(gen/vector gen/char-alphanumeric len)))))
3.4 递归生成器
生成树形结构(如 AST、嵌套 JSON)需要递归。关键用 gen/sized 限制深度防爆栈:
;; 简单的二叉树
(def gen-tree
(gen/sized
(fn [n]
(if (zero? n)
(gen/return :leaf)
(gen/one-of [(gen/return :leaf)
(gen/tuple gen/int
(gen/resize (quot n 2) gen-tree)
(gen/resize (quot n 2) gen-tree))])))))
;; 嵌套 map 结构
(def gen-json
(gen/sized
(fn [size]
(if (zero? size)
(gen/one-of [gen/int gen/string gen/boolean gen/nil])
(gen/one-of [(gen/vector (gen/resize (dec size) gen-json))
(gen/map gen/string
(gen/resize (dec size) gen-json))])))))
gen/resize 在递归时缩减规模,保证生成的树深度受控。
3.5 生成器大小与 scale
;; 默认生成的数据规模较小,用 scale 放大压力
(gen/scale #(* 10 %) (gen/vector gen/int)) ; 生成 0~100 长度向量
;; 限制生成值域
(gen/scale (constantly 5) gen/int) ; 只生成小整数
4. 收缩(Shrinking)
4.1 收缩机制与原理
当属性失败时,test.check 自动尝试把反例缩小到最简,使 bug 可读、可复现。例如某个属性在 [3 -99 7 1000 2] 上失败,shrink 会把 1000 逐步替换为更小值、把向量变短,直到得到一个最小反例。
(defspec find-max-is-in-collection
(prop/for-all [xs (gen/vector gen/int)]
(when (seq xs)
(contains? (set xs) (apply max xs)))))
;; 若实现有 bug(如 max 恒返回 0),报告会是:
;; {:shrunk {:total-nodes-visited 23
;; :depth 5
;; :result false
;; :smallest [0]} }
smallest 就是收缩后的最小反例——通常一眼就能看出问题。
4.2 自定义收缩
默认收缩对内置类型有效。自定义类型可通过 gen/->Generator 指定 :shrink:
(require '[clojure.test.check.generators :as gen]
'[clojure.test.check.rose-tree :as rose])
;; 自定义收缩:把时间戳向 0 收缩
(def gen-timestamp
(gen/->Generator
(fn [rand-num _size]
(let [ts (long (* 1e9 (rand rand-num)))]
(rose/rose (fn [] (when (pos? ts)
[(long (/ ts 2))])) ; 收缩到一半
ts)))
(fn [ts] ...))) ; 可选:自定义生成
4.3 收缩与 spec
基于 spec 的生成器(s/gen)同样支持收缩——spec 生成器内置了类型感知的收缩器,失败时会沿 spec 结构收缩反例。
5. 与 clojure.spec 集成
5.1 s/def + s/gen:自动派生生成器
定义 spec 后,clojure.spec.gen.alpha/s/gen 自动产生对应生成器:
(require '[clojure.spec.alpha :as s]
'[clojure.spec.gen.alpha :as gen-spec])
(s/def ::name string?)
(s/def ::age (s/and int? #(>= % 0) #(<= % 150)))
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::user (s/keys :req-un [::name ::age ::email]))
;; 从 spec 生成
(gen-spec/generate (s/gen ::user))
;; => {:name "8q3R", :age 83, :email "x@y.z"}
;; 生成 3 个样例
(gen-spec/sample (s/gen ::user) 3)
组合 spec(s/and、s/or、s/coll-of、s/keys)都能自动映射到对应生成器,这就是 spec 的「双头」设计——既是校验器又是生成器工厂。
5.2 s/fdef:函数规格
s/fdef 描述函数的参数与返回约束,测试时用 :args 的生成器喂给函数:
(s/def ::pos-num (s/and number? pos?))
(defn divide-safe [a b]
(if (zero? b)
:div-by-zero
(/ a b)))
(s/fdef divide-safe
:args (s/and (s/cat :a ::pos-num :b ::pos-num))
:ret (s/or :number ::pos-num
:error #{:div-by-zero})
:fn (fn [{:keys [args ret]}]
(or (= ret :div-by-zero)
(= ret (/ (:a args) (:b args))))))
5.3 instrument:运行时校验函数
clojure.spec.test.alpha/instrument 在调用点注入参数校验,让属性测试与运行时防御共享同一套规格:
(require '[clojure.spec.test.alpha :as stest])
;; 打开对 divide-safe 的参数校验(测试环境)
(stest/instrument `divide-safe)
;; 之后调用非法参数会立即失败
;; (divide-safe 1 0) 在 instrument 打开时返回 :div-by-zero,参数合法
;; (divide-safe -1 5) 会抛出 :args 校验异常
;; 关闭
(stest/unstrument `divide-safe)
instrument 与生成式测试的组合拳:开发时用生成器找 bug,运行时用 instrument 防回归。
5.4 用 spec 属性测试业务函数
(defspec user-validation-properties
(prop/for-all [u (gen-spec/gen (s/gen ::user))]
(s/valid? ::user u) ; spec 生成器必产生合法数据
(= u (clojure.walk/postwalk-replace {} u)))) ; 任意业务性质
6. 集成测试策略
6.1 属性测试在 CI 中的位置
| 层级 | 工具 | 定位 |
|---|---|---|
| 单元测试 | clojure.test | 快、确定性、覆盖显式分支 |
| 属性测试 | test.check | 大量随机输入、找边界缺陷 |
| spec instrument | clojure.spec.test | 运行时参数防御 |
| 集成测试 | 真实依赖 | 跨模块正确性 |
推荐分层:每个核心纯函数配套一组属性测试,数据库/HTTP 集成保持少量示例测试。
6.2 种子复现与调试
失败时 test.check 返回 :seed,用种子精确复现:
;; 指定种子重新运行
(tc/quick-check 100 props {:seed 1727312345})
;; 或作为 defspec 参数
(defspec reproducible-property
100
{:seed 42}
(prop/for-all ...))
6.3 生成器调试技巧
;; 打印生成器产出,验证形状是否符合预期
(gen-spec/sample (gen/vector gen/string-alphanumeric 0 5) 10)
;; 检查生成值域
(gen-spec/sample (gen/fmap #(mod % 100) gen/nat) 10)
;; => (0 42 7 99 3 ...)
6.4 与 clojure.test 并存
defspec 本身就是 deftest,可以混合使用:
(deftest example-based-tests
(is (= 3 (my-fn 1 2))))
(defspec my-fn-properties
100
(prop/for-all [a gen/int b gen/int]
(= (my-fn a b) (+ a b))))
7. 常见陷阱与最佳实践
7.1 陷阱清单
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 生成器产生无效输入 | 大量误报失败 | 用 gen/fmap/gen/bind 构造合法域,或 prop/for-all 前 s/assert |
| 生成器过大(爆栈) | 递归生成死循环 | gen/sized + gen/resize 控制规模 |
| 测试次数过少 | 随机性漏测 | 默认 100,热区提至 1000~10000 |
| 忽略 seed | 失败不可复现 | CI 记录并回放 seed |
| 断言太弱 | 测试恒通过 | 写「最强性质」,如与参考实现对照 |
| 在属性中依赖副作用 | 结果不确定 | 属性测试只测纯函数 |
7.2 从失败的属性到回归测试
属性测试发现 bug 后,把 :smallest 最小反例固化为传统单元测试,防止回归:
;; 属性测试失败,报告 smallest = [0 0]
(defspec some-prop ...) ; 修复后重跑
;; 固化回归测试
(deftest regression-0-0
(is (thrown? ArithmeticException (my-div 0 0))))
7.3 实战清单
- 核心纯函数全部配属性测试:排序、解析、序列变换、金额计算。
- 用模型对照:新算法 vs 朴素参考实现,能自动发现几乎所有逻辑错误。
- 生成器从 spec 派生:
s/gen保证输入合法,减少无效输入噪声。 - 测试次数与生成规模平衡:CI 快速跑 100 次小输入,夜间跑 1000 次大输入。
- 组合生成业务对象:用
gen/bind让相关字段一致(如 start ≤ end)。
7.4 模型对照完整示例
测试一个高效算法(如快速排序)是否正确,与朴素实现对照:
;; 参考实现
(defn naive-sort [v]
(when (seq v)
(cons (apply min v)
(naive-sort (filter #(not= % (apply min v)) v)))))
;; 被测试的高效实现(假设用 clojure.core/sort)
(defspec sort-matches-naive
200
(prop/for-all [v (gen/vector gen/int)]
(= (naive-sort v) (sort v))))
sort-matches-naive 在 200 组随机输入上对照两种实现——这种「模型对照」是属性测试最强的应用形态。
8. 总结
| 能力 | 工具 | 一句话 |
|---|---|---|
| 生成器组合 | gen/fmap、gen/bind、gen/sized | 从基础类型组合出任意复杂合法输入 |
| 随机测试 | prop/for-all + defspec | 验证性质而非示例 |
| 收缩 | test.check 自动 shrink | 失败时给出最小可读反例 |
| spec 集成 | s/gen、s/fdef、stest/instrument | 校验器自动成为生成器,运行时防御 |
| CI 集成 | defspec + 种子复现 | 融入既有测试体系,失败可复现 |
生成式测试的价值不在于「替代」单元测试,而在于捕捉示例测试必然漏掉的边界缺陷。当你写好一组强性质 + spec 描述,Clojure 的测试体系就完成了从「写例子」到「描述规则」的跃迁。延伸阅读可参考 Clojure spec 与测试 了解 spec 定义与符合性检查的完整语法。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。