summaryrefslogtreecommitdiff
path: root/content/article/clj9.md
diff options
context:
space:
mode:
authoreltrac <hi@eltr.ac>2026-11-03 09:31:51 +0800
committereltrac <hi@eltr.ac>2026-11-03 22:06:00 +0800
commitd2e43610f89b0c80c42eb38bef726d5cb7ba2c80 (patch)
treec882f910b185889ba15c1cf4b742da986ebfae03 /content/article/clj9.md
parent3b02353138b48e34d3f24a4636e9ec7420fc3903 (diff)
[draft] new
[draft] new
Diffstat (limited to 'content/article/clj9.md')
-rw-r--r--content/article/clj9.md166
1 files changed, 166 insertions, 0 deletions
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 <ns>` 指定命名空间,或者 `lein test :only <ns>/<test>` 指定命名空间下的某个测试函数。
+
+直接运行 `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 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。
+