blob: 982ed340b9b4e96501594f6abe0764d1d0f4b742 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。
|