Common Lisp实践:建立单元测试框架
单元测试框架是一种工具或库,用于帮助开发人员创建、组织、运行和报告代码的单元测试。
单元测试是指对软件系统中最小的可测试单元(通常是一个函数或方法)进行的验证测试。
常见的单元测试框架包括:
- Python: unittest、pytest
- Java: JUnit
- C++: Google Test (gtest)
- JavaScript: Jest、Mocha
- C#: NUnit
单元测试框架的意义
- 保证代码质量:
- 提前发现潜在的错误。
- 避免重复引入已修复的bug。
 
- 简化调试和维护:
- 通过测试定位问题点。
- 提升代码可维护性。
 
- 加速开发:
- 通过自动化测试减少手动验证的工作量。
 
- 增强信心:
- 确保修改代码不会破坏已有功能。
 
- 推动良好开发习惯:
- 提高模块化和解耦性。
 
一 两个最初的尝试
假设我们要为内置的“+”函数编写测试,那么下面这些可能是合理的测试用例:
| 1 | (= (+ 1 2) 3) | 
当然不必挨个进行测试,可以写个函数来执行上述测试用例。
| 1 | (defun test-+ () | 
无论何时,当想运行这组测试用例时,都可以调用test-+,一旦它返回T,就知道所有测试用例通过了。但返回NIL时,说明有测试用例失败了,但无法确定是哪一个。为了找出哪些用例不通过,我们可以这样修改test-+函数。
| 1 | (defun test-+ () | 
现在每个测试用例都单独报告结果。format指令中 ~:[FAIL~;Pass~]会在format的第一个格式实参为假时打印FAIL,其他情况下为Pass。执行test-+函数如下:
| 1 | CL-USER> (test-+) | 
当然这个版本的test-+函数也存在一些问题,首先是对format的重复调用过于冗余,尤其后面的测试表达式也存在重复,这些都急需重构。
二 重构
我们真正需要的应该是像第一个test-+那样能返回单一的T或NIL值的高效函数,但同时又能像第二个版本那样单独报告错误。
我们可以创建一个新函数来消除重复的format调用。
| 1 | (defun report-result (result form) | 
现在可以使用report-result来代替format。
| 1 | (defun test-+ () | 
接下来需要摆脱的是测试用例表达式的重复,理想的情况应该将表达式同时看作代码(为了获得结果)和数据(用来作为标签)。无论何时,若想将代码作为数据来看待,这就意味着需要一个宏。代码可能要写出下面这样:
| 1 | (check (= (+ 1 2) 3)) | 
并让其与下列形式等同。
| 1 | (report-result (= (+ 1 2) 3) '(= (+ 1 2) 3)) | 
很容易写出一个宏来做这种转换。
| 1 | (defmacro check (form) | 
,form:插入表达式 (= (+ 1 2) 3) 的值(直接展开)。
',form:插入表达式 (= (+ 1 2) 3) 的符号表示形式,等价于 '(= (+ 1 2) 3)。
现在可以改变test-+来使用check。
| 1 | (defun test-+ () | 
既然不喜欢重复的代码,为什么不把check的重复也一起消除掉。我们可以定义check来接受任意数量的形式并将它们中的每个都封装在一个对report-result的调用里。
| 1 | (defmacro check (&body forms) | 
- 
外层 progn:- progn是 Common Lisp 中的顺序执行结构。它依次执行其中的表达式,并返回最后一个表达式的值。
 
- 
loop循环:- loop for f in forms:遍历- forms中的每个表达式。
- collect:将处理后的结果收集成一个列表。
- (report-result ,f ',f):对每个表达式- f,生成一个新的表达式:- ,f:将- f的内容作为求值部分插入。
- ',f:将- f的原始符号形式插入为数据。
 
 
- 
,@的作用:- ,@将- loop返回的列表“解包”,作为- progn的多个子表达式插入。
 
现在我们可以这样写出test-+。
| 1 | (defun test-+ () | 
三 修复返回值
接下来修改test-+以使返回值可以指示所有测试用例是否都通过了。首先对report-result做点改变,让其可以在报告时顺便返回测试用例结果。
| 1 | (defun report-result (result form) | 
现在report-result返回了它的测试结果,但我们不能简单地使用AND,因为其存在短路,一旦某个测试用例失败就跳过了其余的测试。我们真正需要的是一个像AND那样的操作符,同时又没有短路行为。虽然Common Lisp不提供这样的构造,但我们通过宏可以轻松实现它。我们所需要的宏应该如下所示,现将其称为combine-results
| 1 | (combine-results | 
并且上述代码应该与下面的代码等同。
| 1 | (let ((result t)) | 
编写这个宏的唯一麻烦之处,需要在展开式中引入一个变量,即前面代码中的result,在宏展开式中使用一个变量的字面名称会导致抽象层面出现漏洞,因此需要使用函数 GENSYM在其每次调用时返回唯一的符号,该符号在全局范围内唯一,避免用户代码中可能存在的变量名冲突。使用一个宏可以简化该过程。
| 1 | (defmacro with-gensyms ((&rest names) &body body) | 
通过使用with-gensyms,我们可以这样定义combine-results。
| 1 | (defmacro combine-results (&body forms) | 
现在改变check来使用combine-results
| 1 | (defmacro check (&body forms) | 
使用这个版本的check, test-+就可以输出它的三个测试表达式结果,并 返回T以说明所有测试用例都通过了。
| 1 | CL-USER> (test-+) | 
如果改变一个测试用例而让其失败,最终的返回值也会变成NIL
| 1 | CL-USER> (test-+) | 
四 更好的结果输出
如果编写了大量测试,可能就需要以某种方式将它们组织起来,而不是将它们全部塞进一个函数里。例如,假设想要对“*” 函数添加一些测试用例,可以写一个新测试函数。
| 1 | (defun test-* () | 
现在有了两个测试函数,如果想用一个函数来运行所有测试用例,可以这样实现:
| 1 | (defun test-arithmetic () | 
为什么能复用test-arithmetic,我们通过combine-results的展开式就可以知道。
| 1 | (LET ((#:G245 T)) | 
上述代码,当评估(TEST-+)的时候,就是评估了所有(TEST-+)下的测试用例。test-arithmetic的运行结果如下。
| 1 | CL-USER> (test-arithmetic) | 
所有的用例都使用Pass来表示通过,如果能在测试结果中显示每个测试用例来自什么函数就好了,因为打印相关的代码使用report-result来表示,生成report-result的check并不知道它是从什么函数被调用的,这就意味着还需要改变调用check的方式,向其传递一个参数使其随后传递给report-result。
设计动态变量就是用于解决这类问题的。如果创建一个动态变量使得每个测试函数在调用check之前将其函数名绑定在其之上,那么report-result就可以无需理会check来使用它了。
第一步在最上层声明这个变量。
| 1 | (defvar *test-name* nil) | 
对report-result稍微改动一些,使其在输出中包括*test-name*
| 1 | (format t "~:[FAIL~;Pass~] ... ~a: ~a~%" result *test-name* form) | 
为了让*test-name*生效,还需要改变上述两个测试函数。
| 1 | (defun test-+ () | 
现在结果都被正确打上了标签:
| 1 | CL-USER> (test-arithmetic) | 
五 抽象诞生
现在已经实现的代码看似完整了,但还有值得改进的地方,比如对于测试函数test-+或者test-*,每个函数都需要包含其函数名两次,一次作为DEFUN中的名字,另一次在*test-name*的绑定里。导致重复是因为此时测试函数只做了一半抽象。为了得到一个完整抽象,需要用一种方法来表达“这是一个测试函数”,并且这种方法要能将所需的全部代码都生成出来。换句话说,你需要一个宏。
由于需要捕捉的模式是一个DEFUN加上一些样板代码,所以需要写一个宏使其展开成DEFUN,然后使用该宏去定义测试函数,可以将其命名为deftest。
| 1 | (defmacro deftest (name parameters &body body) | 
使用该宏,可以像下面这样重写test-+。
| 1 | (deftest test-+ () | 
六 测试层次体系
如果想要将上千个测试用例组织在一起,建立合理的测试层次就很好理解。如果用deftest来定义诸如test-arithmetic这样的测试套件函数,并且对其中的*test-name*作一个小改变,就可以用测试用例的“全称”路径来报告结果,就像下面这样:
| 1 | pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3) | 
因为已经抽象了测试函数的过程,所以就无需修改测试函数的代码从而改变相关的细节。为了使*test-name*保存一个测试函数名的列表而不只是最近进入的测试函数的名字,只需将绑定形式:
| 1 | (let ((*test-name* ',name)) | 
变成:
| 1 | (let ((*test-name* (append *test-name* (list ',name)))) | 
由于APPEND返回一个由其实参元素所构成的新列表,这个版本将把*test-name*绑定到一个含有追加其新名字到结尾处的*test-name*的旧内容的列表。每当一个测试函数返回时,*test-name*原有的值将被恢复。
现在可以用deftest代替DEFUN来重新定义test-arithmetic。
| 1 | (deftest test-arithmetic () | 
当然,test-+和test-*也应该用deftest定义。调用test-arithmetic结果如下。
| 1 | CL-USER> (test-arithmetic) | 
随着测试套件的增长,可以添加新的测试函数层次,只要用deftest来定义,结果就会正确输出。
| 1 | (deftest test-math () | 
将如下输出。
| 1 | CL-USER> (test-math) | 
九 总结
完整代码如下。
| 1 | (defvar *test-name* nil) | 
编写测试用例步骤:
对测试用例进行分类,例如"+"函数的合理分类为,test-+ << test-math << test-arithmetic
- 先编写 test-+的测试用例
| 1 | (deftest test-+ () | 
- 可选的,将 test-+放到一个更大的分类下,例如test-arithmetic
| 1 | (deftest test-arithmetic () | 
- 可选的,将test-arithmetic放到一个更大的分类下,例如test-math
| 1 | (deftest test-math () | 
- 执行测试,执行最上层函数test-math就能执行所有的测试用例。