From d2e43610f89b0c80c42eb38bef726d5cb7ba2c80 Mon Sep 17 00:00:00 2001 From: eltrac Date: Tue, 3 Nov 2026 09:31:51 +0800 Subject: [draft] new [draft] new --- content/article/clj9.md | 166 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 content/article/clj9.md (limited to 'content/article') diff --git a/content/article/clj9.md b/content/article/clj9.md new file mode 100644 index 00000000..982ed340 --- /dev/null +++ b/content/article/clj9.md @@ -0,0 +1,166 @@ ++++ +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 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。 + -- cgit v1.2.3