作为一名 Linux C++ 程序员,我自己手搓了个单元测试库轮子,来辅助与满足日常开发 的单元测试需求。从只有一个 tinytast.hpp 头文件开始,后面逐渐添加了一些外围功 能,觉得不一定适合坚持 header-only 库的原则,就将非核心的功能写在单独的 *.cpp 源文件中,编译为静态库。代码开源在 github ,国内的 gitee 也有备份。
我觉得编写单元测试的问题可以从以下几个层次来讲,从微观到宏观。
1.断言语句;
2.单元测试用例设计;
3.单元测试用例运行与管理;
4.单元测试库、框架与集成的设计;
5.可测试程序的一般原则。
下面,我将结合个人开发 couttast 这个单元测试库的思路,谈谈本人对这些单元测试 问题的理解。重点是前三点。
题外话,我在前公司是使用过 gTest 的。几年前来到现公司尴尬地发现没有单元测试 的风气,且当初项目对集成第三方库的管理一言难尽,就想从省事角度不想多引入三方库 增加构建的麻烦。加之之前在使用 gTest 时也遇到一些痛点与不便,就决定自己手搓 一个单元测试库或框架吧,根据自己实际遇到的需求逐步加料。
关于 tast 这个词的命名,原是从尝试 (taste) 删减一个字母以便与 test 等长而 来。
一、微观语句的判断与断言
我说测试是从尝试开始的。不妨先抛开所谓单元测试的行话,回顾下我们最初学习编程时 是怎么测试(或调试)程序的,最原始也很有效的办法就是 printf 大法。所以我在 couttast 库中设计的最核心的宏 COUT 就来源于 C++ 版的打印法门 std::cout 。
譬如说,我们开发个加法函数 Add() ,用 printf 大法可能就是这样写测试:
#include <stdio.h>int Add(int, int);int main(int argc, char** argv)
{
int sum = Add(1, 1);
printf("%d\n", sum);
return 0;
}
很显然,它可能就在终端打印出一个 2 来,你看到 2 被打印出来,就知道加法函数 实现对了,否则就实现错了。
但这里有个问题,只打印一个 2 太孤单。如果要测多种情况,用该办法简单扩展,它 就会打印一行行数字,你还得对照源代码一行行看每个结果是否正确。更有甚者,我使用 printf 经常会忘记加 "\n" ,那就更糟糕了,结果将挤成一行数字无法分辨。
现在若采用 couttast 库的 COUT 宏改写这个“测试用例”或尝试用例:
#include "couttast/tinytast.hpp"int Add(int, int);int main(int argc, char** argv)
{
int sum = Add(1, 1);
COUT(sum);
COUT(Add(1, 1));
return 0;
}
这将打印类似如下的输出:
sum =~? 2
Add(1, 1) =~? 2
它会将表达式及其结果一起打印出来。仅就这个示例而言,你只需用其中一条 COUT 语 句即可。如果要测多种情况,像这样 COUT 平铺下去,因为表达式与结果一起打印,也 就能更方便直接从输出结果判断各种情况计算得对不对。
另注:couttast 的实际输出还会在每行前有两个前导格式字符,本文叙述从略。连接 表达式与结果的中间符号不用 = 或 == ,是因为后面的结果只是一种文本化的打印 表示,真实值不一定是简单可打印的数字或字符串,也许是自定义对象。所以用 =~ 表 示匹配,而不是全等的意思。而再加 ? 是表示结果不确定的疑问,需要进一步判断结 果是否正确。
现在,对结果是否正确的判断,仍与 printf 大法一样,依靠的是程序员自己的眼睛与 大脑。好像还很低级不是?但莫急,这只是 COUT 宏单参数的基本用法与印象,它可以 简单直接地扩展为双参数宏,将预期结果值也传入。例如:
#include "couttast/tinytast.hpp"int Add(int, int);int main(int argc, char** argv)
{
int sum = Add(1, 1);
COUT(sum, 2);
COUT(Add(1, 1), 2);
return 0;
}
输出如下:
sum =~? 2 [OK]
Add(1, 1) =~? 2 [OK]
这与单参数 COUT 宏的输出结果主体一样,只是末尾多了一个 [OK] 标签,表示该语 句测试通过。如果后面谁不小心改动了 Add() 的实现,导致 1+1 计算出 3 的结 果了,再次运行这个测试程序就会报错,如下输出:
Add(1, 1) =~? 3 [NO]
Expect: 2
Location: (出错语句在源代码的文件行数位置)
所以,这其中的意义是,将判断结果是否正确的任务委托给程序(couttast 库)了, 当然期望的正确值还是需要由程序员预先写在测试用例中的,但程序员只要对此分析判断 一次,以后的重测(回归测试)就交由程序自动完成了,这就是单元测试的根本需求。
用过 gTest 单元测试库的朋友容易想到,这里的 COUT 双参数宏与它的 EXPECT_EQ 功能类似,即如下两条语句差不多表达同一种断言意义:
COUT(expr, expect);
EXPECT_EQ(expr, expect);
只不过,COUT 的输出更冗余或更丰富一些。同时,保留单参数 COUT 宏,也有现实 意义。因为实际开发中的被测函数,不会做加法这么简单(当然对自定义对象重载加法也 可能不简单),有时候在写单元测试时,对特定输入用例,你还不能立即在头脑中反映出 正确输出。这时,你就可以先写单参数 COUT ,把结果打印出来看看,验证一下,确实 正确,再把正确结果当成预期值填回到 COUT 的第二参数中。
可能有人会觉得这属于投机取巧行为,甚至担心有不负责的程序员,先用单参数 COUT 跑一次,然后不管其输出结果正确与否,就粘贴回 COUT 的第二参数中,以便让单元 测试通过。然而,这不是技术问题,所以也无法通过技术手段解决。即使是使用 gTest ,也可以在断言语句前加断点,在调式器中把当前结果拷出来啊。
其实还有一种情况,如边界测试。有些客户需求可能就没对边界情况作明确界定,那它就 是未定义需求,允许未定义行为,或者说是依赖实现的确定性未定义行为。就比如 C++ 社区喜欢重造字符串库的轮子,比如要写个字符串拆分 split 函数,如果分隔符在开 头或结尾怎么办,忽略还是算一个空串,这或许在两可之间。
如此就可以先实现,怎么简便怎么来。写边界用例时若只从代码作理论分析,可能比较烧 脑,那就先用单参数 COUT 将结果打印出来,结合具体结果再来分析这样的输出是否合 理,是否可接受。如果可行,那就将这种实现(或 bug)当特性(feature),固化在单 元测试用例中。
如果后面交付给客户,客户觉得这样的边界处理不符合他的直觉。那就让客户明确边界需 求啊,即使打回来修改,那其他的正常测试用例也能起到回归测试的保障作用。很多时候 是客户不懂得提需求,对某些边界情况也不太介意,但他会问边界会发生什么。那么有设 计边界测试用例时,就容易回答这种问题,知晓客户让他注意即可。
总之,单参数的 COUT 似乎只算是尝试,而双参数的 COUT 就开始通往测试之路了。 在 couttast 单元测试库中,几乎只要记这一个断言宏。对任意自定义类型,只要支持 了 << 与 == 操作符重载,也就能放在 COUT 宏中。至少要支持 == 操作,如果 不想重载 << 操作,则不能用单参数的 COUT 宏,而双参数 COUT 可改写如下等效 形式:
COUT(expr == expect, true);
同样可举一反三,用 COUT 来断言其他比较关系。
二、单元测试用例设计
比语句级断言测试更大一层范围的是单元测试用例,它由若干条断言语句及相关上下文处 理一起,组成对某种情况或叫用例的测试。对于普通开发用户而言,这是写单元测试的主 要工作。
单元测试也是一种程序,虽然它追求简单直白甚至达到教学入门级的代码,但它也应该遵 循写代码的一些基本原则。当要测试的情况越来越多,显然不可能将所有断言语句如 COUT 写在一个 main() 函数中,那就要拆分子函数了。
至于如何拆分函数,那就不仅是技术问题,更是业务问题了。故这里无法具体讲怎么拆解 ,只说拆解后,再如何组织起来调用。显然,最原始的办法就是在 main() 函数中显式 顺序调用这些测试子函数。而在 couttast 单元测试库中,提供了两个宏,让定义与调 用单元测试用例子函数更自动化一些。简单示例如下:
// 用户开发库int Add(int a, int b) { reutrn a + b; }
#include "couttast/tinytast.hpp"// 定义单元测试用例DEF_TAST(test_add, "加法基本测试"){
COUT(Add(1, 1), 2);
COUT(Add(1, -1), 0);}
// 自动调用测试用例int main(int argc, char** argv){
return RUN_TAST(argc, argv);}
其中,DEF_TAST 用于定义一个测试用例,从用户角度看,它就相当于定义一个最简单 的 void() 函数,参数与返回都是 void ,类似于:
// DEF_TAST(test_add, "加法基本测试")void test_add() // 加法基本测试{
COUT(Add(1, 1), 2);
COUT(Add(1, -1), 0);}
一般而言,需要用 DEF_TAST 定义多个测试用例,并且可以分布于不同的 .cpp 源文 件中。只要在其中一个(或单独一个)源文件中写个 main() 函数,而在 main() 中 只要调用 RUN_TAST 宏转发命令行参数,就可以调用所有被链接在一起的源文件中用 DEF_TAST 定义的单元测试用例。
用 DEF_TAST 定义测试用例,相比平凡的 void() 子函数,除了可被自动调用外,还 有个额外好处:在运行用例前会有额外一行输出表明当前在运行哪个用例,运行完后会统 计当前用例中有多少条断言 COUT 语句失败,并汇报运行时间。
基本原理就这么简单,讲完了。也并不比 COUT 复杂多少。而 COUT 与 DEF_TAST 这两个关键宏名合并起来,就是 couttast 的库名。
当然了,在写具体的非平凡的单元测试用例时,可能会遇到各自的特定业务问题。但只要 记得一条,把每个测试用例当作一个 void() 函数来写,让每个 void() 函数都可以 像 main() 入口函数一样独立运行。测试程序也是一种程序,运行你自己熟知的编程习 惯与技巧,把这些 void() 函数组织起来即可。
另外我想指出一点的是,couttast 不推荐使用面向对象的方便组织单元测试用例,你 只需写好每个 void() 函数,而不用考虑先如何自定义一个测试用例类(并继承库内部 的单元测试基类)。如果有许多单元测试用例都需要写一份相同的初始化代码(与清理代 码),把它们提取到一个单独的函数中,或类中,然后在每个测试用例的开头显式调用一 下。例如:
struct TestSuit{
TestTuit() {/* 初始代码 */}
~TestTuit() {/* 清理代码 */}};
DEF_TAST(TestSuit_aaa, "测试 TestSuit 的一个用例"){
TestSuit self;
...}
DEF_TAST(TestSuit_bbb, "测试 TestSuit 另一个用例"){
TestSuit self;
...}
在一般的类开发中,可能会更常见提供非平凡的构造函数,以便在构造中初始化成员状态 。但在写单元测试中,不妨就从简单开始,在默认构造函数中给各成员赋上确定的值,用 于其他一系列测试。当需要另一份状态数据测试时,再考虑封装个其他构造函数。
这就比将测试用例(通过某种技巧)继承 TestSuit 类更灵活,也更直观。尤其是当要 复用两个类的初始化代码时,也可以直接在用例函数中定义几个类对象。而继承,难道要 祭出 C++ 的一大杀器之多重继承来踩坑么?这就是常说的组合优于继承原则。
从另一实现角度看其实也与如下方式提出通用辅助测试函数的解决办法差不多:
void test_add(int left, int right, int expect){
COUT(left);
COUT(right);
COUT(left + right, expect);}
// 这个 DEF_TAST 其实也可以取名 test_add ,不会真与上面 void 函数重名DEF_TAST(test_add_basic, "相加基本测试"){
test_add(1, 1, 2);
test_add(1, -1, 0);
...}
提取出的这个 test_add() 函数,先用单参数 COUT 把操作数打印出来,再用双参数 COUT 断言结果。对于简单整数相加当然是没必要的,但如果是自定义对象呢,在初始 开发自测时,把参数一起打印出来是有调试意义的。
所以,这些所谓的技巧,本质都差不多,源于初中数学的提取公因式思想,增加代码重用 性。而重复代码的产生,在一定程度上也是从 main() 中拆解子函数的代价交换有关, 毕竟所有代码写在一起,很多初始化动作就天然地只要写一次。
最后,写单元测试代码时,主要谨记一个简单性原则,一个用例内以顺序结构为主,最好 不要有分支与循环,除调用被测函数外,其他辅助测试函数的调用链不要太深。这才能达 到以简驭繁的效果。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理