Coffee Break Clojure, Test

假设有函数 pair-sum,把输入的数字划分为两个一对,然后对每个对求和,返回和组成的序列:

(defn pair-sum [& nums]
 (->> nums
 (partition 2)
 (map (partial apply +))))

系统运行一段时间后有人反馈了 Bug,复现方式如下:

(pair-sum 3 2 1 4 5 3 5)
;; => (5 5 8)

输入上述用例,函数只返回了前六个数字组成的三个对的和,最后一个 5 被丢弃了。经过判断,这的确是个 Bug,解决方案是让落单的输入单独作为和返回。

你当然可以修完 Bug 之后一走了之,但试想一下:之后的贡献者在看到这段代码的时候,会不会对「落单的输入怎么处理」这件事情产生不同的想法?他会不会认为修复后的版本反而是个 Bug?

当然可以在文档和 Issues 里留档,但不是所有人都会把所有文档资料扫一遍再改代码的,忽略注释也没有代价。有没有办法让代码的行为被修改之后,就立刻报错提醒修改者「这里不该这么改」?

Testing, Testing, 1, 2, 3

标准库 clojure.test 提供了编写测试的工具,先引入命名空间:

(ns my.peculiar-math-test
 (:require [clojure.test :refer [deftest testing is]]))

我们引入了三个符号:

  • deftest:顾名思义,用于定义测试,类似于在命名空间里定义函数。基本上可以把测试函数当作没有参数的普通函数,测试内部还能调用其他测试函数。
  • testing:写在测试函数内的更小单元,需要填写字符串来描述测试的范围、目的等等;测试函数内部可以有多个 testing,testing 还能嵌套。
  • is:断言,只有在传给 is 的是真值时才会通过测试。

测试一般写在 test/ 目录下,和 src/ 相对应。在顶层目录运行 lein test 就可以执行所有测试用例,也可以 lein test 指定命名空间,或者 lein test :only / 指定命名空间下的某个测试函数。

直接运行 lein test 会发现有 Bug 的代码顺利通过了测试。这是句废话,因为我们根本没写测试。从现在开始,应当认为这是异常情况:Bug 竟然通过了测试?先来解决这个问题,写测试用例捕获刚才的 Bug。

(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)

改代码,尝试通过测试:

(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 就是 TDD 的践行者。据他所言,先写测试再写代码其实要比硬着头皮直接写更快。

I got you covered

当然不能为系统里的每个函数都编写测试,仅在命名空间使用的工具函数就无需测试,支撑业务逻辑的关键函数就要重点测试。在我看来,一个模块的导出(export)函数是必须要测试的。不过 Clojure 的模块没有导出的概念,只要不是 defn- 定义的私有函数,使用 defn 定义的函数都是默认公开可引用的。

可以把模块的一两个被其他模块调用的函数当作导出函数,还可以把导出函数的概念类比为 API。两者都是位于边界位置的存在,导出函数位于模块边界,API 位于系统边界。

模块的概念不需要太过强调,封装性很多时候也只会徒增麻烦,比起纠结哪个函数是导出函数,我更在意哪些函数被测试完全之后,能够保证测试覆盖率最高。

测试覆盖率(test coveragec)是指完整的测试之后,被执行的代码所占总代码的比例。代码的单位可以是行数、语句、分支、分支条件和条件组合等等(见白盒测试的各种概念)。对 Lisp 来说,还可以用形式(forms)作为单位。

Leiningen 插件 cloverage 就是自动化的测试覆盖率计算工具。在项目根目录运行 lein cloverage,这个插件会先对代码插桩(instrument),然后对插桩的代码运行 test/ 目录下的测试,最后生成报告。

以下是 weepinbell 37fd5d4d31 版本的覆盖率报告:

|--------------------------------------+---------+---------|
| 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 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。

I Spec with my little eye

Clojure 有一个从 2016 年推出起就备受关注,如今也被广泛应用,但仍然还在 Alpha 阶段的标准库,叫 clojure.spec.alpha。

众所周知,Clojure 是动态类型语言,也常常因此遭人诟病。毕竟 JavaScript 在被广泛投入生产使用之后,也有人非要造个 TypeScript 出来,可见业界有多么讨厌动态类型语言。

动态类型语言的缺陷的确不少,比方说非常依赖运行时类型推断,性能通常不如静态类型语言,而且大部分代码不需要通过编译器的类型检查,或者说类型检查非常宽松。对字符串做 + 操作之所以能在 Go 语言等静态类型语言里放心使用,是因为类型明确,不会出现 JavaScript 里 "1" + 1 == "11" 的情况(不过这其实是 JavaScript 的设计缺陷,而不是所有动态类型语言的缺陷)。

著名的 Lisp 黑客,也是硅谷创业之父 Paul Graham 在《黑客与画家》里也谈到编程语言选择,他觉得动态类型语言更优,至少开发者体验是最好的。用户体验(UX)很重要,开发者体验(DX)也很重要,开发者感觉良好才能有效率地创造出精良的软件。

所以选动态类型语言也没什么羞耻的,毕竟写起来真的很爽。动态不代表不稳定,用户感知到的不是数据的类型是否可变,而是系统是否稳定好用、行为是否正确,而这两者很容易通过编写测试来保障。完全不写测试的静态类型语言程序照样容易漏洞百出。

静态类型语言的一大优势是编译器能够做很多检查,比方说类型传错了、缺了某个字段,就无法编译,必须停下来修复问题。动态类型语言不修类型问题也能运行,这很爽,但确实是隐患。

clojure.spec.alpha 就用来解决这个问题,可以用它定义一系列数据规格(spec),再用函数检查数据是否符合规格。

定义规格并没有引入新的类型系统,spec 只是谓词函数而已。

(ns my.spec
 (:require [clojure.spec.alpha :as s]))

(s/def ::username string?)

s/def 用于定义规格,而 ::username 是一类特殊的关键字(keyword)。一般的关键字只有一个冒号,带两个冒号的关键字是带命名空间的关键字(namespaced keyword)。第二个参数是个函数,string? 判断输入值是否为字符串。

用 s/valid? 判断数据是否合规:

(s/valid? ::username "Alice")
;; => true

(s/valid? ::username 231)
;; => false

如果要在另一个命名空间里引用:

(require '[my.spec])

(s/valid? :my.spec/username "Bob")
;; => true

可以用 s/and 和 s/or 构建更复杂的规格:

;; 用户名必须是字符串,且长度小于 20
(s/def ::username
 (s/and string? #(< (count %) 20)))

;; 昵称可以为空,也可以是合法的用户名
(s/def ::nickname
 (s/or :no-nickname nil?
 :has-nickname (partial s/valid? ::username)))

s/or 需要为每一种情况命名,用关键词表示。

还需要经常指定的规格是映射(map),可以用 s/keys 检查某个映射里面是否包含指定的键,以及这些键是否符合各自的规格。

(s/def ::user
 (s/keys :req [::username ::age ::gender]
 :opt [::nickname]))

{::username "Eltrac"} 不合法,因为缺失 ::age 和 ::gender 两个键。下面这个映射符合 ::user 规格吗?

{::username "Alice"
 ::age 21
 ::gender :female
 ::nickname :ali}

不合法,因为可选键 ::nickname 不符合 ::nickname 的规格,它要么为 nil,要么为合法的 ::username,也就是说必须是长度小于 20 的字符串。

下面这个数据符合 ::user 规格吗?

{:username "Alice"
 :age 21
 :gender :female
 :nickname "ali"}

不符合,因为这上面的键都是普通关键词,不带命名空间。

要让不带命名空间的键也能通过规格检查,可以修改规格定义:

(s/def ::user
 (s/keys :req-un [::username ::age ::gender]
 :opt-un [::nickname]))

也就是把 :req(required,必须)改成 :req-un,把 :opt(optional,可选)改成 :opt-un,这里的 un 代表 unqualified。

效果是一样的,区别在于使用不带命名空间的键的 map 也能通过检查了。

如果不理解为什么某个数据没有通过检查,可以用 s/explain:

(s/explain ::nickname 123)
;; 123 - failed: nil? at: [:no-nickname] spec: :user/nickname
;; 123 - failed: (partial valid? :my.spec/username) at: [:has-nickname] spec: :user/nickname
;; => nil

上面输出的信息显示,123 既没通过 :no-nickname 的 nil? 检查,也没通过 :has-nickname 的 (s/valid? :my.spec/username) 检查,所以不合格。

clojure.spec.alpha 里常用的函数就是这些了,里面还有不少方便的函数,可以到 ClojureDocs 里面阅读。

To err is to human

我在 第 101 期周刊 里提到了 怂货式编程 ,Ivan Grishaev 观察到很多人会写出这样的 Clojure 代码:

(defn double-number [x]
 (when x
 (* x 2)))

也就是说,只有 x 不为 nil 的时候,才会执行 (* x 2),因为对 nil 做算术运算会报错。一些人害怕报错,所以要在函数里面写 when,防止操作 nil 数据。

乍一看很合理,没有人希望程序报错。可是不报错不代表没有问题,如果 nil 被传给这个函数,函数本身也会返回 nil。在函数式系统里,数据管线往往是一个接一个的函数调用,如果最后得出的结果是 nil,而中间没有一个函数报错,我要怎么才能知道是哪个函数出了问题呢?

Grishaev 认为,如果输入是 nil,这个函数就不该被调用。如果输入值有问题,这个函数不应该胆小地缩在角落不敢说话,应该大喊大叫,“这里出问题了!你们传了个什么数据给我?!喂,程序员,快把问题修好!”

除了让类型操作直接报错,还可以显式地断言(assert)类型问题。这里的断言和测试断言不太一样,稍后会讲到。

(defn double-number [x]
 {:pre [(number? x)]
 :post [#(number? %)]}
 (* x 2))

写在形参后面的 {} 是可选的,可以在里面写 :pre 或者 :post,叫作 Pre-conditional 和 Post-conditional。前者用于检查参数的类型,在函数体之前执行;后者用于检查返回值的类型,在函数体之后执行。

:pre 的值是向量,里面通常是若干个对谓词函数的调用,总之要返回布尔值。在这里面可以直接访问参数。

:post 也是向量,不过里面写的就不是函数调用了,而是函数本身。可以是函数符号,也可以匿名函数,无论是什么函数,都必须只接收一个参数,返回布尔值。之所以不直接调用函数,是因为我们此时不持有对返回值的引用。

clojure.spec.alpha 很适合与 conditional 一起用:

(defn create-user [username]
 {:pre [(some? username) (s/valid? ::username username)]
 :post [(partial s/valid? ::user)]}
 {::username username
 ::age nil
 ::gendar nil})

写在 :pre 和 :post 里的任何一个条件判断返回假值,Clojure 都会抛出 AssertionError,然后给出具体是哪个断言没有成功。这就很方便排错了。

这和测试断言 is 不一样,测试断言是在运行测试时才会出现的,但这里的 AssertionError 会在实现代码里出现。理想情况下,哪种断言都不该出现在生产环境里,程序在编译并发布之前就应该把所有的测试失败和断言失败都修复了。

不过也难免会有漏掉的,如果不想让用户看到断言错误,可以在编译 uberjar 的时候,把 clojure.core/*assert* 设置为 false。

要怎么尽可能避免漏掉断言错误,尽可能保证类型匹配呢?

运行一遍覆盖率较高的测试集,这样就能保证大部分断言都被执行过一次,也就可以在测试阶段发现尽可能多的 AssertionError。


  1. 如果需要大量拼接,建议使用 strings.Builder,直接用 + 拼接字符串非常内存低效。
参与讨论