-
Notifications
You must be signed in to change notification settings - Fork 1
1 基础仅头文件库
仅需一个头文件 tinytast.hpp
就能实现单元测试核心功能,满足大多数写单元测试代码的需求。couttast 代码库中其他头文件与源文件都是围绕该核心头文件的扩展功能,满足或方便特定单元测试的场景,却未必是每个用户必需的。 tinytast.hpp
兼容 C++11 之前的旧标准语法。
-
COUT
是最常用的语句级宏,观察并比较、断言变量或表达式的值; -
DEF_TAST
定义单元测试用例,一个测试用例相当于一个void
函数,其中可能有多条COUT
语句; -
RUN_TAST
运行所有单元测试用例,一般只要在main()
入口函数调用一次,可自动调用测试程序所有用DEF_TAST
的单元测试用例。
最简示例如:
#include "couttast/tinytast.hpp"
DEF_TAST(test_name, "典型测试用例")
{
COUT(sizeof(int));
COUT(sizeof(int), 4);
COUT(sizeof(int)==4, true);
COUT(sizeof(std::string), 8);
}
int main(int argc, char** argv)
{
return RUN_TAST(argc, argv);
}
这就是一个完备的测试程序,仅包含一个单元测试用例(函数),编译运行,可向终端打印良好格式的输出信息,汇报单元测试成功与否的结果。
COUT
是本单元测试库核心宏,就是取名自标准库的 std::cout
。主要有两种用法,单参数与双参数。
单参数 COUT(expr)
,打印 expr
表达式字面量及其值,使其输出信息更有可读意义,且更易找到源文件互为参照。支持任意类型,只要其支持 <<
操作重载。
双参数 COUT(expr, expect)
,在单参数的基础上扩展,比较 expr
与 expect
的值,如果相等,在行尾打上 [OK]
的标记,否则打上 [NO]
的标记。比较失败后也会在下面两行打印 expect
的值,及该语句在源码中的位置(文件与行号)。表达式与预期值的类型可以不同,只要支持 ==
操作重载。
另外浮点数还支持三参数 COUT(expr, expect, limit)
,因为浮点精度原因,直接用 ==
比较浮点数可能不准确,所以允许提供额外参数指定精度。
COUT
可用于表达式,返回 bool
类型,也即比较结果,而单参数下固定返回 true
。此外,还有两个语义不变式。
原则一、对于绝大多数类型,以下两个 COUT
断言语句结果等效同义:
COUT(expr, expect);
COUT(expr == expect, true);
原则二、对于绝大多数类型,COUT
的单参数与双参数输出内容,除后缀判断标记外相同:
COUT(expr);
COUT(expr, expect); // 仅在输出末尾增加 [OK] 或 [NO] 判断标记
但对于 C 字符串类型,const char*
或 char*
时,情况有点特殊。单参数时输出字符串内容,双参数时输出内容虽然也相同,但比较的是指针值,这是为了满足原则一。其他类型的指针,输出其十六进制地址值,比较的也是地址值。
若要比较 C 字符串的内容,除了显式用 strcmp
外,也可在 COUT
中将其中一个参数转换为 std::string
,以便使用 C++ 字符串的 ==
重载运算符,如:
COUT(expr, std::string(expect));
COUT(strcmp(expr, expect), 0);
对于浮点数比较,支持如下几种方法使用 COUT
:
double expr, expect, limit;
COUT(expr, expect, limit);
COUT(abs(expr-expect) <= limit, true);
COUT(expr, expect);
其中第一种的三参数形式与第二种的显式比较 true
等效。若只有双参数浮点数,采用计算机表示浮点的最大精度比较,对于简单浮点数,也能通过相等性判断。但在具体业务中若浮点数结果是从其他复杂的浮点计算而来,可能引入计算误差,理应根据业务需求增加第三参数表示比较精度。
自定义类型通过重载操作符,可用于 COUT
宏参数:
- 单参数
COUT
要求支持<<
操作符。 - 双参数
COUT
要求至少支持==
操作符,最好也要支持<<
操作符。
如果自定义类型只重载了 ==
,而不想麻烦重载 <<
,则双参数断言宏可写成 COUT(expr == expect, true)
,将宏参数转换为 bool
类型。
对于断言宏还有两个扩展变种,可选使用。
-
COUTF(expr, expect)
: 只有当断言失败时才输出信息,用于减少成功时的输出信息。 -
COUT_ASSERT(expr, expect)
: 断言失败时会因return
终止当前测试函数,可避免因一个关键语句失败导致后面连续失败甚至异常。
DEF_TAST(name, desc)
,相当于定义测试函数 void name()
,并且为该测试用例附加一段描叙说明。典型用法如下:
DEF_TAST(name, "optional description for this test case")
{
// function body
}
其中第一参数是符号名,要求是合法的程序标识符。第二参数一般是双引号括起的字符串字面量,可省略,但建议加上。随后的大括号内是测试函数体,在运行该测试用例时将运行的代码块。
在 couttast
库内部管理中,对每个测试用例保存着一个 void
类型的函数指针,指向宏后面的大括号。除了提供给宏的两个显式参数外,还保存了该宏定义出现的源文件与行号,源文件不包括目录名,但包括后缀名(一般是 .cpp
)。
测试用例名及描叙,建议不要超过 255 字符,每个测试源文件控制在六万行规模以下。相信正常程序都可满足该需求。
最后,测试用例信息还保存了一个 bool
变量,标记它是否自动测试用例,即由 DEF_TAST
定义还是由下面的 DEF_TOOL
定义。
DEF_TOOL(name, desc)
, 用法与 DEF_TAST
类似,仅一个差别:在默认情况下未指定命令行参数时,不会自动执行这类单元测试用例。适于定义那些主要用单参数 COUT
观察输出,或运行时间过长不适合自动执行的特殊用例。
所以 DEF_TOOL
的非自动有两层意思。一是单参数 COUT
没有断言能力,要用户(程序员)自己观察判断结果是否正确。二是没有命令行参数显式指定的话,默认不会被运行,避免浪费运算。前者用法是后者设计的原因。
手动或半自动的意义在于,有时追求全自动是很难的,尤其是开发初期,可能一时想不到如何设计自动测试用例写断言。所以 couttast
允许你先简单写手动测试用例,观察结果,自测感觉没问题再补上 COUT
的第二个参数。
另一个附加的意义是,你也可以利用 DEF_TOOL
快速调用被测目标库做个实用小工具,通过输入命令行参数输入不同数据,在交互使用中也是起到手动测试目标库的作用。
这两个宏定义的测试用例,被放在同一个容器统一管理,所以它们的用例名不允许重名。在不同文件或不同命名空间内的 DEF_TAST
或 DEF_TOOL
虽然允许重名,不影响编译,但也应尽量避免,因为重名用例无法通过命令行参数精确指定。
运行测试用例只有一个宏 RUN_TAST
,且一般只要在 main
函数中调用一次。
当没有输入任何命令行参数时,效果等同无参调用 RUN_TAST()
,其意为依次调用所有由 DEF_TAST
定义的测试用例。调用顺序由底层容器存储确定。
如果有命令行参数,RUN_TAST(argc, argv)
将依次解析每个命令行参数,调用名字匹配该参数的测试用例。也支持一些命令行选项,详见下节。
RUN_TAST
返回失败用例个数,全部用例通过则返回 0。故可直接当作 main
的返回值,当作测试程序的退出码。
RUN_TAST
宏对应的实现方法也支持一些其他重载参数,但一般用不着显式调用。
如果提供一个字符串参数 RUN_TAST(const std::string& name)
,一般是来自命令行参数,则执行如下逻辑:
- 如果参数形如
file.cpp:line
,则按文件名与行号查找匹配的用例运行。 - 如果名字完全与某个测试用例名相等,则只运行该用例。
- 扫描所有测试用例,如果用例名包含参数
name
(子串)则运行匹配的用例。
RUN_TAST(const std::string& file, int line = 0)
将扫描所有由 DEF_TAST
定义的测试用例,如果它所在的文件名包含参数 file
,以及所在行号不小于参数 line
,则运行该测试用例。
注意,为方便起见,目前只支持查找以 .cpp
为后缀的文件名。并且与按用例名查找逻辑不同,不会优先查找同名用例,直接查部分匹配文件名。因为测试源文件命名推荐使用固定前缀加上被测源文件,方便对应,如 test-xxx.cpp
用于测试 xxx.cpp
。所以输入参数 xxx.cpp
就能查找到 test-xxx.cpp
文件内的测试用例。
同时,按文件名查找测试用例,仍然是非确定性的泛指,故不会运行由 DEF_TOOL
定义的半自动测试用例。
除了普通位置参数依次转调 RUN_TAST(name)
宏运行匹配的用例外,利用 tinytast.hpp
编译的测试程序还支持以下一些以 --
引导的参数选项。
如果不记得支持哪些参数选项了,用 --help
会打印简明信息提示可用参数,然后直接退出不执行任何用例,忽略其他参数。
--list
选项也会略过其他参数,跳过用例运行,而只打印出所有定义的测试用例名,每行一个,包括 DEF_TAST
与 DEF_TOOL
,其中后者会附加星号后缀以示区分。
--List
与 --list
意义类似,只是打印更多的信息。每行一个用例,包括用例名,类型(TAST 或 TOOL),所在文件与行号,以及用例描叙(即定义宏的第二可选参数)。每项用制表行分隔,若复制到 Excel 表格自动分列。
测试用例运行时会打印许多信息,详见下节。在自动脚本启动测试程序时可附加 --cout=
选项参数抑制某些输出,具体支持:
-
--cout=fail
:不输出COUT
断言成功的语句,只输出COUT
断言失败的语句,相当于都变为COUTF
效果。 -
--cout=silent
:与fail
类似,但输出的信息还更精简,单参数COUT
输出宏与纯描叙性的DESC
宏也被沉默,不再有输出。一般每个测试用例只有一行综合输出。 -
--cout=none
:所有宏的输出被抑制,只由程序退码是否 0 来判断测试是否通过。当然不能抑制在单元测试用例中用户代码手动调用printf
或std::cout
的输出。
注意参数必须与 --cout=
的等号粘连在一起,不能有空格,否则后者会认为是普通位置参数,尝试查找匹配的用例名了。
由 tinytast.hpp
编译的测试程序,在运行时会打印各种信息,各有不同的前缀字符格式表示,可以据此区分被测函数自身可能打印的输出,或用户在测试程序中自已可能打印的其他输出。
单参数 COUT
是最基本的输出样式,每条语句打印一行。例如,COUT(sizeof(int))
可能会输出:
|| sizeof(int) =~? 4
其中,除 ||
前缀外,=~?
左侧的就是 COUT
宏的参数,右侧是是它的值。因为 COUT
可以是任意类型,右侧只是适合它的文本表示,不一定表示全等关系,所以用 =~
表示。而 ?
表示疑问,不确定这个值是否正确,需要作进一步判断。在单参数下,就是靠用户(程序员)人工核对。
双参数 COUT
断言宏成功的话,与单参数输出极为相似。如 COUT(sizeof(int), 4)
与 COUT(sizeof(int) == 4, true)
可能分别输出:
|| sizeof(int) =~? 4 [OK]
|| sizeof(int) == 4 =~? true [OK]
主要区别就是由程序帮助核对打印的值是否正确(与第二个参数比较),正确时在末尾打上标签 [OK]
。
在双参数 COUT
宏断言失败时,除了末尾的标签改为 [NO]
,还会另起两行打印期望值(即第二参数)以及该语句在源码的位置。如 COUT(sizeof(std::string), 8)
可能输出:
|| sizeof(std::string) =~? 32 [NO]
>> Expect: 8
>> Location: [/path/to/test-file.cpp:LINE](funtion)
注:C++ 标准库的 std::string
早期流行的实现方式是写时复制,栈内就一个指针,所以其大小认为是 8 (64位操作系统)。而现在更多的是小字符串优化实现,栈区大小 32 字符(短字符串不必额外申请堆内存)。所以这里断言 std::string
的大小是 8 字节,极可能是失败的。
位置信息来源于经典的三个宏 __FILE__
与 __LINE__
与 __FUNCTION__
。如果 COUT
用在 DEF_TAST
定义的大括号内,其函数名是基于测试用例名自动生成的,但是也可以用在其他自由函数内。
由 COUT
的输出可见,它具有相当的可读性,在一定程度上不必逐行对照源码,也知道它在比较什么。尤其是在给变量取恰当的名字(传为 COUT
第一参数时)效果更佳。
当你觉得一行行的 COUT
输出仍然有点混沌时,可以在测试源码中间适当增加 DESC
描叙宏,其语义类似 printf
语义的纯输出(自动加换行符)。例如:
DESC("现代编译器对 std::string 的实现普遍是短字符串优化");
COUT(sizeof(std::string), 32);
它可能会输出:
-- 现代编译器对 std::string 的实现普遍是短字符串优化
|| sizeof(std::string) =~? 32 [OK]
DESC
宏的输出前缀 --
来源于 sql
的注释符。同时横线也代表着粗略的分隔,在同一个测试用例内,用 DESC
分隔一些 COUT
语句。所以 DESC
宏本身没实际意义,只是善用之可增加测试源码与测试输出的可读性。
再举个可能的应用场景,对裸大括号注释。写测试代码经常是琐碎的上下复制粘贴再小改,但注意在同一个函数内粘贴容易出现变量重定义的编译错误(所以说 rust
的重绑定变量挺方便),在 C++ 中可以用一对大括号开辟不同作用域来避免。例如:
DEF_TAST(math_power, "测试平方实现")
{
DESC("测试正数");
{
int result = math::power(3, 2);
COUT(result, 9);
}
DESC("测试负数");
{
int result = math::power(-3, 2);
COUT(result, 9);
}
}
若不用大括号分隔,复制下来的 int result = ...
就要删掉 int
避免重定义;然后复制到其他地方又可能报未定义错误,又要加上 int
。但如果删去 DESC
无意义行,一对对裸大括号又很难看很费解。事实上,如果 DESC
下面的大括号若足够复杂,也可以提出来用 DEF_TAST
专门定义一个测试用例;但若觉得没必要,在一个测试用例简单用 DESC
分隔也很快捷。
此外,DESC
也支持类似 printf
的格式符插入,比如用 DESC("loop %d), i)
于循环中,提示观察几次循环内的变量结果。
就以上小节“测试平方实现”的测试示例,包括首尾输出如下:
## run math_power()
-- 测试正数
|| result =~? 9 [OK]
-- 测试负数
|| result =~? 9 [OK]
<< [PASS] 0 math_power N us
可见在运行测试示例前,抬头会先打印该用例名,前缀 ##
取自 markdown 的二级标题,表明这将是一节输出。
运行完毕后的尾行信息较多,除前缀 <<
外,[PASS]
表示测试通过,0
个错误。如果测试失败,则打印 [FAIL]
,而后面的数字将是个比 0
大的正数,代表该用例内COUT
断言失败语句数。最后 us
是时间单位,微秒,前面的 N
是个具体数字,代表运行这个用例所用耗时的微秒数。
尾行中部还会重新打印(呼应)该用例名。主要是考虑在命令参数指定 --cout=silent
时,一个测试用例仅打印结束时的尾行,才能收集到足够的信息,所以也再补上用例名信息才完整。
每个测试用例尾行输出后,会有一个额外空行,与下一个测试用例的首行隔开。但在 --cout=silent
模式下,没有额外空行。
在所有测试用例的运行输出之后,隔一空行(除非 --cout=silent
),再打印整个测试程序的汇总结果。形如:
## Summary
<< [PASS] N
<< [FAIL] M
!! failed_test_1
!! failed_test_2
...
!! failed_test_M
首先是类似二级标题的 Summary
抬头,然后打印通过用例数 N
,失败用例数 M
。如果失败数大于 0
,其后列出每个失败用例名,用 !!
警示前缀。RUN_TAST
宏的操作最后一步就是汇总结果,其返回值就是失败用例数 M
。
测试程序的典型用法是先不带参数运行全部用例,若发现汇报某个用例失败,再将用例名拷下来作为参数单独运行,调试排查。
以上输出信息默认打印到终端的标准输出,但是利用全局单例的测试用例管理对象 G_TASTMGR
,也可以注册自定义打印函数,其接口形式为:
typedef void (*PrintFun)(const char* str);
void SetPrint(PrintFun fn);
在静态扩展库的颜色打印功能就是通过注册打印回调实现的。
另外要注意,COUT
语句的一行输出,有个上限大约是 1024 字符,避免自定义类型对象的超长文本序列化。自定义回调打印函数所接收到的参数 str
已经是可能被截断的,但也保证了 \0
字符封尾。
使用 tinytast.hpp
编译的单元测试程序,默认有丰富的 COUT
输出信息,这些输出信息也形成了测试用例的特征结果。所以初习者如果不会或懒得写断言语句,也可简单写单参数 COUT
输出语句,再将不同时刻运行的测试输出保存文件,用 diff 比较法模拟回归测试。例如:
./tast_program > tast.cout
# after some time for fix ...
mv tast.cout tast.bout
./tast_program > tast.cout
diff tast.cout tast.bout
不过此法要注意的是测试中不宜输出指针地址,因为不同运行时内存地址很可能不一致。
同时这个手法较为粗糙,更标准备、专业的做法是,有意识地将单参数 COUT
升级为双参数断言,从手动测试转向自动测试。
前文提到,每个测试用例输出的尾行,会打印该用例的耗时(微秒)。根据该时间报告可大致评估测试用例的运行效率。
在测试用例函数体中,还可以使用两个宏 TIME_TIC
与 TIME_TOC
,限定为该用例运行的计时范围。默认省略的情况下,TIME_TIC
相当于写在左大括号之后,而 TIME_TOC
相当于写在于右大括号之前。可以根据实际所需显式调整计时起点或(与)终点。例如:
DEF_TAST(test, "...")
{ //< A
...
TIME_TIC; //< a
...
TIME_TOC; //< b
...
} //< B
如果没有使用 TIME_TIC
与 TIME_TOC
,则计时范围为 AB
;如果使用这两个宏,则计时范围为 ab
;也可能只使用一个宏 TIME_TIC
,则计时范围为 aB
;或者只使用了 TIME_TOC
,则计时范围为 Ab
。
理论上,在函数体内也可以使用多次 TIME_TIC
,则以最后一次为准;如果使用多次 TIME_TOC
,则以第一次为准。也可认为左右大括号隐含写了个 TIME_TIC
与 TIME_TOC
,所以大部分情况下可能都没必要显式使用,更没必要将问题复杂化多次使用。
函数运行时间受系统环境影响在一定范围内有浮动误差,所以如果想要更准确地评估运行时间,可采用多次运行取平均的方法。所以宏 TIME_TAST
就是设计为方便实现该功能,它可接收 1 至 3 个参数,只有第一个参数用例名是必须的,后面两个有默认值:
-
name
: 测试用例名,即由DEF_TAST
(或DEF_TOOL
)定义的名称;或自由定义的void
函数名(无参数与返回值)。 -
times
: 重复运行次数,默认 10 次。 -
msleep
: 每次运行暂停的时间,默认 1000 毫秒,即 1 秒。如果该参数大于 0 ,则还会打印每次运行的时间,除非指定了命令行参数--cout=silent
。 -
return
: 返回值就是平均时间,单位是微秒。
至于为什么要在每次重复运行之间默认暂停一秒,是为了消除时间局部性缓存的影响,相当于是测试冷启动的时间。用户可根据自己的需求决定是否要给第三参数传非 0 值。
被测的函数体,或测试用例的函数体,也可以用 TIME_TIC
与 TIME_TOC
重设计时范围。
由于测时语句总体耗时较长,且非功能测试,所以 TIME_TAST
语句本身,适合于放在另一个 DEF_TOOL
定义的测试用例内,仅在需要时显式指定参数运行。也可以将功能测试与(时耗)性能测试分开编译,物理隔离。
即使用上了多次统计平均,函数运行耗时还是与机器配置有关,因而所得的绝对值结果也可能没有普遍意义。但是可以利用 TIME_TAST
的返回值,比较两个算法实现的相对耗时大小,也就能期望验证一种算法比另一种更快或更慢。