一、Clojure测试和其他语言有啥不一样

1.1 为啥得换测试思路

很多开发刚开始接触Clojure时,会觉得“测试不就是写断言?”,但其实Clojure的测试逻辑和Java、Python这些面向对象的语言差异很大——核心原因就是Clojure的核心是函数式编程,讲究“纯函数”,说白了就是“输入确定,输出就确定,不会偷偷改外面的任何东西”。 比如你用Java写个计算总价的函数,可能会依赖用户对象的属性,或者改全局的购物车状态,这时候测试要测的就不止是函数本身,还要测对象的状态变化、全局变量的修改,还要写各种mock,麻烦得很。但在Clojure里,尽量把业务逻辑写成纯函数,测试的时候根本不用管别的,只需要给不同的输入,看输出对不对就行,省了一大半的功夫。

二、Clojure常用的测试策略

2.1 基础级:纯函数的单元测试

单元测试是最常用的,专门测单个纯函数的逻辑,不用管外部依赖,只要给输入,校验输出就行。举个例子,我们写一个计算商品总价的函数,然后写对应的单元测试:

;; 技术栈:Clojure 1.11 + clojure.test 官方自带单元测试框架
(ns test.demo
  (:require [clojure.test :refer [deftest is testing]]))

;; 纯函数:接收商品列表,返回总价,无任何副作用
(defn calculate-total [items]
  ;; 遍历每个商品,算单价乘数量,再累加
  (reduce + (map #(* (:price %) (:quantity %)) items)))

;; 单元测试:用testing把不同场景分开,方便定位问题
(deftest calculate-total-test
  (testing "正常商品列表的总价计算"
    ;; 测试两个商品的情况,预期总价是20*3 +40*1=100
    (is (= 100 (calculate-total [{:price 20 :quantity 3} {:price 40 :quantity 1}])))
    ;; 测试空列表的边界场景,预期返回0
    (is (= 0 (calculate-total []))))
  (testing "特殊数值的边界场景"
    ;; 测试单价为0的情况,预期总价是50*5 +0*100=50
    (is (= 50 (calculate-total [{:price 10 :quantity 5} {:price 0 :quantity 100}])))
    ;; 测试数量为0的情况,预期返回0
    (is (= 0 (calculate-total [{:price 50 :quantity 0}])))))

这个测试的好处是,不管你调用多少次,只要输入一样,结果就一样,完全不用怕状态被改,测试结果永远稳定。

2.2 进阶级:边界场景与异常测试

光测正常情况还不够,要考虑输入异常的情况,比如输入是空、是nil、是负数这些,这些是bug高发区。比如刚才的calculate-total,如果输入是nil,会直接报错,所以我们要加对应的测试:

;; 继续用上面的calculate-total函数,新增异常测试
(deftest calculate-total-exception-test
  (testing "输入为nil时应该返回0而不是报错"
    ;; 先修改calculate-total,把nil转成空列表:(defn calculate-total [items] (reduce + (map ... (or items []))))
    (is (= 0 (calculate-total nil)))
  (testing "输入商品的price为负数时的处理"
    ;; 假设业务要求单价不能为负,加判断返回异常
    (let [bad-item {:price -10 :quantity 2}]
      (is (thrown? IllegalArgumentException (calculate-total [bad-item]))))))

这里用thrown?断言函数会抛出指定的异常,确保边界场景的逻辑正确。

2.3 业务级:集成测试

如果函数需要和外部服务(比如数据库、Redis、API)交互,就需要集成测试,测真实的调用逻辑,不能只mock。比如我们写个从Redis拿用户积分求和的函数,对应的集成测试:

;; 技术栈:Clojure 1.11 + clojure.test + Carmine(Clojure Redis客户端)
(ns test.integration-demo
  (:require [clojure.test :refer [deftest is testing]]
            [taoensso.carmine :as car]))

;; 业务函数:从Redis批量拿用户积分,求和
(defn get-users-total-points [user-ids]
  (->> user-ids
       ;; 调用Redis拿每个用户的积分,转成整数
       (map #(Integer. (car/wcar {} (car/get (str "user:" % ":points")))))
       (reduce +)))

;; 集成测试:需要连接真实的Redis服务,这里用测试环境的Redis
(deftest get-users-total-points-test
  (testing "正确计算多个用户积分总和"
    ;; 先预置测试数据到Redis
    (car/wcar {} (car/set "user:1:points" "100"))
    (car/wcar {} (car/set "user:2:points" "200"))
    ;; 调用函数,预期总和300
    (is (= 300 (get-users-total-points [1 2]))))
  (testing "单个用户无积分时返回0"
    ;; 删除测试用户3的积分数据
    (car/wcar {} (car/del "user:3:points"))
    (is (= 0 (get-users-total-points [3])))))

集成测试的缺点是需要依赖外部服务,但能测出真实环境下的交互问题,和单元测试互补。

2.4 边界级:属性测试

这是Clojure里特别好用的测试方式,和单元测试手动写例子不同,属性测试是机器自动生成几百种随机输入,验证函数是否符合核心属性。比如calculate-total的核心属性是“总价一定等于每个商品单价乘数量的和”,用test.check实现:

;; 技术栈:Clojure 1.11 + test.check 属性测试框架
(ns test.property-demo
  (:require [clojure.test :refer [deftest]]
            [clojure.test.check :as tc]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop]))

;; 复用之前的calculate-total函数
(defn calculate-total [items]
  (reduce + (map #(* (:price %) (:quantity %)) items)))

;; 定义属性:任意合法的商品列表,总价符合计算规则
(def total-correct-property
  (prop/for-all [;; 生成随机商品列表,每个商品的price和quantity都是非负整数
                 items (gen/vector (gen/hash-map :price gen/nat :quantity gen/nat))]
    (let [total (calculate-total items)]
      ;; 属性1:总价等于逐项乘积之和
      (= total (reduce + (map #(* (:price %) (:quantity %)) items)))
      ;; 属性2:总价不可能为负(输入都是非负的前提下)
      (>= total 0))))

;; 运行属性测试,生成100组随机输入验证
(deftest calculate-total-property-test
  (let [result (tc/quick-check 100 total-correct-property)]
    (is (:pass? result))))

属性测试能覆盖你单元测试没写的边界,比如超长列表、大数值、零值组合,帮你提前发现隐藏的bug。

三、常见测试问题排查方法

3.1 测试不先通,先查函数是不是纯的

如果你的测试有时候过有时候不过,大概率是函数有副作用——比如改了全局变量、打印了日志、调用了没加mock的外部服务。纯函数的核心是“输入输出一致”,如果测试结果波动,先去查函数里有没有用atom、ref这些可变状态,或者有没有IO操作(比如println、HTTP调用),把这些副作用的部分抽成独立的函数,业务逻辑单独写成纯函数,测试就会稳定。

3.2 测试超时,排查并发与状态竞争

如果测试总是超时,或者多线程跑测试失败,大概率是并发或状态竞争的问题。Clojure的状态管理虽然比别的语言简单,但如果用swap!操作atom时,多线程同时改同一个值,可能出现重试延迟;如果用了refs但没加事务,也可能出现不一致。排查时可以把测试改成单线程跑,看是否还失败,如果单线程过,就是并发的问题,改用事务或更简单的状态管理方式。

3.3 测试覆盖率低,用spec补类型校验

如果分支覆盖率低,比如函数里有if-else但没测到,或者出现类型错误(比如把字符串当数字用),可以用Clojure的clojure.spec来定义函数的参数和返回值类型,自动校验。比如定义商品的规格,要求price和quantity都是非负整数,调用函数时如果参数不符合,会自动报错,不用手动写测试覆盖类型场景,减少代码量也减少bug。

四、实际开发的避坑技巧

4.1 警惕nil陷阱

nil是Clojure里的特殊值,很多函数对nil的处理和空列表不同,比如map对nil会返回nil,而对空列表会返回空序列,所以如果函数的入参可能是nil,一定要先转成空列表,比如(or items []),避免出现空指针或意外结果。比如之前的calculate-total函数,如果不处理nil,输入nil时会报错,加了(or items [])后返回0,符合业务逻辑。

4.2 少用全局可变状态

如果业务里必须用状态(比如购物车、用户会话),尽量用atom、ref这些Clojure的状态管理工具,但不要在函数里直接用全局变量,最好把状态作为参数传给函数,或者做成函数的一部分,这样函数更容易测试,不会因为全局变量的变化导致测试失败。

五、总结

Clojure的测试策略核心是“先把业务逻辑拆成纯函数,用单元和属性测试覆盖,带副作用的部分用集成测试”,优点是测试简单稳定,不用写太多mock,能快速覆盖边界场景;缺点是带IO的集成测试需要依赖外部服务,稍微麻烦。注意事项是尽量把业务逻辑写成纯函数,把副作用抽离到最外层,用spec做类型校验,避免nil陷阱。这个策略适合数据处理、微服务业务逻辑、工具函数等场景,能显著减少bug,提升代码质量。