+++ title = 'Coffee Break Clojure, Test' subtitle = '' slug = '' tags = [] categories = '' date = 2026-11-03T08:57:32+08:00 draft = true toc = true +++ 假设有函数 `pair-sum`,他把输入的数字划分为两个一对,然后对每个对求和,返回和组成的序列: ```clojure (defn pair-sum [& nums] (->> nums (partition 2) (map (partial apply +)))) ``` 系统运行一段时间后有人反馈了 Bug,复现方式如下: ```clojure (pair-sum 3 2 1 4 5 3 5) ;; => (5 5 8) ``` 输入上述用例,函数只返回了前六个数字组成的三个对的和,最后一个 `5` 被丢弃了。经过判断,这的确是个 Bug,解决方案是让落单的输入单独作为和返回。 你当然可以修完 Bug 之后一走了之,但试想一下:之后的贡献者在看到这段代码的时候,会不会对「落单的输入怎么处理」这件事情产生不同的想法?他会不会认为修复后的版本反而是个 Bug? 当然可以在文档和 Issues 里留档,但不是所有人都会把所有文档资料扫一遍再改代码的。有没有办法让代码的行为被修改之后,就立刻报错提醒修改者「这里不该这么改」? ## Testing, Testing, 1, 2, 3 标准库 `clojure.test` 提供了编写测试的工具,先引入命名空间: ```clojure (ns peculiar-math-test (:require [clojure.test :refer [deftest testing is]])) ``` 我们引入了三个符号: - `deftest`:顾名思义,用于定义测试,类似于在命名空间里定义函数。基本上可以把测试函数当作没有参数的普通函数,测试内部还能调用其他测试函数。 - `testing`:写在测试函数内的更小单元,需要填写字符串来描述测试的范围、目的等等;测试函数内部可以有多个 `testing`。 - `is`:断言,只有在传给 `is` 的是真值时才会通过测试。 测试一般写在 `test/` 目录下,和 `src/` 相对应。在顶层目录运行 `lein test` 就可以执行所有测试用例,也可以 `lein test ` 指定命名空间,或者 `lein test :only /` 指定命名空间下的某个测试函数。 直接运行 `lein test` 会发现刚才我们发现的 Bug 顺利通过了测试。这是句废话,因为我们根本没写测试。从现在开始,应当认为这是异常情况:Bug 竟然通过了测试?先来解决这个问题,写测试用例捕获刚才的 Bug。 ```clojure (ns my.peculiar-math-test (:require [clojure.test :refer [deftest testing is]] [my.peculiar-math :refer [pair-sum]])) (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`,会发现测试失败了! ``` lein test my.peculiar-math-test lein test :only my.peculiar-math-test/pair-sum-test FAIL in (pair-sum-test) (peculiar_math_test.clj:7) Extra number that is not paired should be included in the result expected: (= (pair-sum 1 1 1) (quote (2 1))) actual: (not (= (2) (2 1))) Ran 1 tests containing 1 assertions. 1 failures, 0 errors. Subprocess failed (exit code: 1) ``` 这时改代码,尝试通过测试: ```clojure {hl_lins=[3]} (defn pair-sum [& nums] (->> nums (partition-all 2) (map (partial apply +)))) ``` `partition` 函数会主动丢弃无法组成足够长度分区的元素,而 `partition-all` 函数就不会。 再次执行测试,发现测试通过了。 ``` lein test my.peculiar-math-test Ran 1 tests containing 1 assertions. 0 failures, 0 errors. ``` 本来只需要添加四个字符就可以解决的问题,我们却多写了个测试文件,测试代码都快跟实现代码差不多长了(甚至可以更长),何必呢? 对于 `pair-sum` 这种逻辑简单的小函数,测试可能帮助不大,但正如前文所说,如果之后代码的行为改变了,原来的 Bug 再次出现,只要运行测试就能立马捕捉到问题。这对复杂的业务逻辑来说尤其有帮助,如果代码的依赖关系较多,修改一个地方造成另一个看起来毫不相干的 Bug 这种事情,常常发生,而测试可以让我们提前发现这些问题。 除了专门用来捕获错误的测试,还可以在编写函数的一开始就写出正确的用例,也就是 Happy Path——程序在理想情况下应该执行的路径。 也可以试着在编写实现代码之前就写出测试用例,然后以「通过测试」为目标编写代码,这种开发方式叫作测试驱动开发(Test-Driven Development),[Uncle Bob](https://blog.cleancoder.com/) 就是 TDD 的践行者。据他所言,先写测试再写代码其实要比硬着头皮直接写更快。 ## I got you covered 当然不能为系统里的每个函数都编写测试,仅在命名空间使用的工具函数就无需测试,支撑业务逻辑的关键函数就要重点测试。在我看来,一个模块的导出(export)函数是必须要测试的。不过 Clojure 的模块没有导出的概念,只要不是 `defn-` 定义的私有函数,使用 `defn` 定义的函数都是默认公开可引用的。 可以把模块的一两个被其他模块调用的函数当作导出函数,还可以把导出函数的概念类比为 API。两者都是位于边界位置的存在,导出函数位于模块边界,API 位于系统边界。 模块的概念不需要太过强调,封装性很多时候也只会徒增麻烦,比起纠结哪个函数是导出函数,我更在意哪些函数被测试完全之后,能够保证**测试覆盖率**最高。 测试覆盖率(test coveragec)是指完整的测试之后,被执行的代码所占总代码的比例。代码的单位可以是行数、语句、分支、分支条件和条件组合等等(见白盒测试的各种概念)。对 Lisp 来说,还可以用(forms)作为单位。 Leiningen 插件 [cloverage](https://github.com/cloverage/cloverage) 就是自动化的测试覆盖率计算工具。在项目根目录运行 `lein cloverage`,这个插件会先对代码插桩(instrument),然后对插桩的代码运行 `test/` 目录下的测试,最后生成报告。 以下是 [weepinbell](https://codeberg.org/eltrac/weepinbell) `37fd5d4d31` 版本的覆盖率报告: ```text |--------------------------------------+---------+---------| | Namespace | % Forms | % Lines | |--------------------------------------+---------+---------| | weepinbell.config | 100.00 | 100.00 | | weepinbell.core | 18.66 | 49.02 | | weepinbell.http.client | 100.00 | 100.00 | | weepinbell.http.downloader | 99.50 | 100.00 | | weepinbell.metadata.html-head | 99.30 | 100.00 | | weepinbell.metadata.jf2 | 100.00 | 100.00 | | weepinbell.metadata.json-ld | 100.00 | 100.00 | | weepinbell.metadata.microformats | 81.69 | 89.90 | | weepinbell.migration | 76.15 | 100.00 | | weepinbell.processor.facade | 76.26 | 100.00 | | weepinbell.processor.janitor | 100.00 | 100.00 | | weepinbell.processor.storage-checker | 90.14 | 91.67 | | weepinbell.processor.verifier | 81.38 | 100.00 | | weepinbell.processor.wrapper | 100.00 | 100.00 | | weepinbell.processor.writer | 100.00 | 100.00 | | weepinbell.storage.edn-storage | 97.83 | 100.00 | | weepinbell.storage.h2 | 93.92 | 98.08 | | weepinbell.storage.worker | 79.40 | 100.00 | | weepinbell.utils.digest | 100.00 | 100.00 | | weepinbell.utils.url | 98.67 | 98.25 | | weepinbell.webhook | 80.83 | 100.00 | | weepinbell.webhook-presets | 90.65 | 91.67 | | weepinbell.webserver.counter | 100.00 | 100.00 | | weepinbell.webserver.facade | 81.25 | 95.24 | | weepinbell.webserver.feed | 100.00 | 100.00 | | weepinbell.webserver.handler | 89.64 | 100.00 | | weepinbell.webserver.receiver | 100.00 | 100.00 | | weepinbell.webserver.retriever | 100.00 | 100.00 | |--------------------------------------+---------+---------| | ALL FILES | 85.07 | 95.99 | |--------------------------------------+---------+---------| ``` 除了总的 85.07% 形式覆盖率和 95.99% 的代码行覆盖率(也就是说,在运行完整的测试集之后,对应比例的 Lisp forms 和代码行被执行了),还有各个命名空间的覆盖率。此外 cloverage 还会生成 HTML 报告,可以在网页里查看具体是哪些代码行没有被完整覆盖。 要注意的是,被覆盖的代码不代表没有问题,只代表被执行。测试是否可靠,还要看测试用例设计得是否合理,测试断言写得是否恰当(即便跑了测试,不写断言,测试复盖率也可以很高)。 改完代码之后跑一遍测试,如果测试覆盖率较高且用例设计合理,就能提前捕获到没预想到的问题。这对贡献者也有帮助,刚来的贡献者不熟悉代码库,测试能帮他们快速发现自己有没有改错东西。 测试通过不代表系统没有问题!记住,Bug 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。