内容提要
文章以Clojure函数pair-sum丢弃落单元素为例,介绍用clojure.test编写测试捕获Bug,并说明测试驱动开发、测试覆盖率工具cloverage及导出函数应重点测试;随后介绍clojure.spec.alpha定义数据规格、校验数据;最后主张用前置/后置条件断言尽早暴露错误,而非静默返回nil。
延伸解读
测试的真正价值:防止行为回退
文章以 pair-sum 为例,修复 Bug 只需将 partition 改为 partition-all,但作者仍建议补写测试。原因在于测试能锁定函数行为,防止未来贡献者无意中改回错误实现。对于依赖复杂的业务逻辑,修改一处可能引发看似无关的 Bug,测试能提前暴露这类问题。测试代码长度接近甚至超过实现代码,在简单函数上看似不划算,但长期维护中,测试作为行为契约的价值远高于编写成本。
覆盖率是参考,不是质量保证
cloverage 能统计 forms 和行覆盖率,并生成 HTML 报告定位未覆盖代码。但文章强调,被覆盖的代码只代表被执行,不代表没有问题。测试是否可靠取决于用例设计和断言是否合理——即使覆盖率很高,不写断言也能达到高覆盖率。因此,覆盖率应作为发现遗漏的工具,而非测试质量的唯一标准。测试通过也不代表系统无 Bug,漏网之鱼始终存在。
spec 与前置/后置条件:在边界处大声报错
clojure.spec.alpha 用谓词函数定义数据规格,可组合 s/and、s/or、s/keys 等检查复杂结构,s/explain 能说明数据为何不合规。文章进一步主张用 :pre 和 :post 条件断言替代“怂货式编程”——即用 when 静默返回 nil 的写法。前置条件检查参数,后置条件检查返回值,任一失败即抛出 AssertionError,便于在数据管线中快速定位问题源头。spec 可与条件断言结合,在函数入口和出口处校验数据。
Q&A
Clojure 的 pair-sum 函数为什么在处理奇数个参数时会丢失最后一个元素?
因为 pair-sum 使用了 partition 函数,该函数会主动丢弃无法组成足够长度分区的元素。当输入元素个数为奇数时,最后一个元素无法组成一对,因此被丢弃。
如何用 clojure.test 为 pair-sum 函数编写测试来捕获落单元素被丢弃的 Bug?
在 test/ 目录下创建测试命名空间,引入 clojure.test 的 deftest、testing、is,然后编写测试用例,例如 (deftest pair-sum-test (testing "Extra number that is not paired should be included in the result" (is (= (pair-sum 1 1 1) '(2 1))))) 。运行 lein test 即可执行测试并发现失败。
cloverage 是什么?它如何帮助评估测试覆盖率?
cloverage 是 Leiningen 插件,用于自动化计算测试覆盖率。运行 lein cloverage 后,它会先对代码插桩,再运行 test/ 目录下的测试,最后生成报告,显示各命名空间的形式覆盖率和行覆盖率,并生成 HTML 报告查看未覆盖代码行。
clojure.spec.alpha 的主要用途是什么?如何定义和校验数据规格?
clojure.spec.alpha 用于定义数据规格(spec)并校验数据是否符合规格。使用 s/def 定义规格,如 (s/def ::username string?),然后用 s/valid? 判断数据是否合规,如 (s/valid? ::username "Alice") 返回 true。还可以用 s/and、s/or、s/keys 构建复杂规格。
在 Clojure 函数中,:pre 和 :post 条件断言有什么作用?如何与 clojure.spec.alpha 结合使用?
:pre 和 :post 是函数的前置条件和后置条件断言,分别用于检查参数和返回值。:pre 在函数体之前执行,:post 在函数体之后执行。它们可以结合 clojure.spec.alpha 使用,例如 {:pre [(s/valid? ::username username)] :post [(partial s/valid? ::user)]},当条件不满足时会抛出 AssertionError。
为什么说测试通过不代表系统没有问题?如何提高测试的有效性?
因为测试可能没有覆盖所有情况,或者断言设计不合理。被覆盖的代码只代表被执行,不代表没有问题。提高测试有效性需要设计合理的测试用例和恰当的断言,并追求较高的测试覆盖率,同时结合条件断言尽早暴露错误。