Clojure 生成式测试实战:test.check、收缩与 spec 集成

深入 Clojure 生成式(属性驱动)测试:test.check 生成器组合(gen/fmap、gen/bind、递归生成器)、随机测试与种子复现、收缩(shrinking)机制与自定义、与 clojure.spec 的 s/def + s/fdef + instrument 集成、在 CI 与 clojure.test 中的集成测试策略。

传统单元测试验证「几个手写的示例」,而生成式测试(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 instrumentclojure.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 实战清单

  1. 核心纯函数全部配属性测试:排序、解析、序列变换、金额计算。
  2. 用模型对照:新算法 vs 朴素参考实现,能自动发现几乎所有逻辑错误。
  3. 生成器从 spec 派生:s/gen 保证输入合法,减少无效输入噪声。
  4. 测试次数与生成规模平衡:CI 快速跑 100 次小输入,夜间跑 1000 次大输入。
  5. 组合生成业务对象:用 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 定义与符合性检查的完整语法。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 领域建模与事件溯源:DDD、CQRS 与函数式实现
  2. Clojure 性能优化与 GraalVM 原生编译
  3. ClojureScript 全栈开发:reagent、re-frame 与 shadow-cljs 实战