Skip to content

1 基础仅头文件库

lymslive edited this page May 1, 2024 · 3 revisions

仅需一个头文件 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 语句宏详解

COUT 是本单元测试库核心宏,就是取名自标准库的 std::cout 。主要有两种用法,单参数与双参数。

单参数 COUT 观察宏

单参数 COUT(expr) ,打印 expr 表达式字面量及其值,使其输出信息更有可读意义,且更易找到源文件互为参照。支持任意类型,只要其支持 << 操作重载。

双参数 COUT 断言宏

双参数 COUT(expr, expect) ,在单参数的基础上扩展,比较 exprexpect 的值,如果相等,在行尾打上 [OK] 的标记,否则打上 [NO] 的标记。比较失败后也会在下面两行打印 expect 的值,及该语句在源码中的位置(文件与行号)。表达式与预期值的类型可以不同,只要支持 == 操作重载。

另外浮点数还支持三参数 COUT(expr, expect, limit),因为浮点精度原因,直接用 == 比较浮点数可能不准确,所以允许提供额外参数指定精度。

COUT 基本语义

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 要求至少支持 == 操作符,最好也要支持 << 操作符。

如果自定义类型只重载了 == ,而不想麻烦重载 <<,则双参数断言宏可写成 COUT(expr == expect, true) ,将宏参数转换为 bool 类型。

COUT 变体

对于断言宏还有两个扩展变种,可选使用。

  • COUTF(expr, expect): 只有当断言失败时才输出信息,用于减少成功时的输出信息。
  • COUT_ASSERT(expr, expect): 断言失败时会因 return 终止当前测试函数,可避免因一个关键语句失败导致后面连续失败甚至异常。

定义测试用例宏

自动测试用例 DEF_TAST

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

DEF_TOOL(name, desc), 用法与 DEF_TAST 类似,仅一个差别:在默认情况下未指定命令行参数时,不会自动执行这类单元测试用例。适于定义那些主要用单参数 COUT 观察输出,或运行时间过长不适合自动执行的特殊用例。

所以 DEF_TOOL 的非自动有两层意思。一是单参数 COUT 没有断言能力,要用户(程序员)自己观察判断结果是否正确。二是没有命令行参数显式指定的话,默认不会被运行,避免浪费运算。前者用法是后者设计的原因。

手动或半自动的意义在于,有时追求全自动是很难的,尤其是开发初期,可能一时想不到如何设计自动测试用例写断言。所以 couttast 允许你先简单写手动测试用例,观察结果,自测感觉没问题再补上 COUT 的第二个参数。

另一个附加的意义是,你也可以利用 DEF_TOOL 快速调用被测目标库做个实用小工具,通过输入命令行参数输入不同数据,在交互使用中也是起到手动测试目标库的作用。

这两个宏定义的测试用例,被放在同一个容器统一管理,所以它们的用例名不允许重名。在不同文件或不同命名空间内的 DEF_TASTDEF_TOOL 虽然允许重名,不影响编译,但也应尽量避免,因为重名用例无法通过命令行参数精确指定。

运行测试用例宏

转发 main 命令行参数

运行测试用例只有一个宏 RUN_TAST,且一般只要在 main 函数中调用一次。

当没有输入任何命令行参数时,效果等同无参调用 RUN_TAST() ,其意为依次调用所有由 DEF_TAST 定义的测试用例。调用顺序由底层容器存储确定。

如果有命令行参数,RUN_TAST(argc, argv) 将依次解析每个命令行参数,调用名字匹配该参数的测试用例。也支持一些命令行选项,详见下节。

RUN_TAST 返回失败用例个数,全部用例通过则返回 0。故可直接当作 main 的返回值,当作测试程序的退出码。

按用例名执行测试

RUN_TAST 宏对应的实现方法也支持一些其他重载参数,但一般用不着显式调用。

如果提供一个字符串参数 RUN_TAST(const std::string& name) ,一般是来自命令行参数,则执行如下逻辑:

  1. 如果参数形如 file.cpp:line ,则按文件名与行号查找匹配的用例运行。
  2. 如果名字完全与某个测试用例名相等,则只运行该用例。
  3. 扫描所有测试用例,如果用例名包含参数 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 帮助信息

如果不记得支持哪些参数选项了,用 --help 会打印简明信息提示可用参数,然后直接退出不执行任何用例,忽略其他参数。

--list 或 --List 列出所有测试用例

--list 选项也会略过其他参数,跳过用例运行,而只打印出所有定义的测试用例名,每行一个,包括 DEF_TASTDEF_TOOL ,其中后者会附加星号后缀以示区分。

--List--list 意义类似,只是打印更多的信息。每行一个用例,包括用例名,类型(TAST 或 TOOL),所在文件与行号,以及用例描叙(即定义宏的第二可选参数)。每项用制表行分隔,若复制到 Excel 表格自动分列。

--cout= 设置输出信息冗余度

测试用例运行时会打印许多信息,详见下节。在自动脚本启动测试程序时可附加 --cout= 选项参数抑制某些输出,具体支持:

  • --cout=fail :不输出 COUT 断言成功的语句,只输出 COUT 断言失败的语句,相当于都变为 COUTF 效果。
  • --cout=silent :与 fail 类似,但输出的信息还更精简,单参数 COUT 输出宏与纯描叙性的 DESC 宏也被沉默,不再有输出。一般每个测试用例只有一行综合输出。
  • --cout=none :所有宏的输出被抑制,只由程序退码是否 0 来判断测试是否通过。当然不能抑制在单元测试用例中用户代码手动调用 printfstd::cout 的输出。

注意参数必须与 --cout= 的等号粘连在一起,不能有空格,否则后者会认为是普通位置参数,尝试查找匹配的用例名了。

运行测试输出格式

tinytast.hpp 编译的测试程序,在运行时会打印各种信息,各有不同的前缀字符格式表示,可以据此区分被测函数自身可能打印的输出,或用户在测试程序中自已可能打印的其他输出。

COUT 输出格式

单参数 COUT 是最基本的输出样式,每条语句打印一行。例如,COUT(sizeof(int)) 可能会输出:

|| sizeof(int) =~? 4

其中,除 || 前缀外,=~? 左侧的就是 COUT 宏的参数,右侧是是它的值。因为 COUT 可以是任意类型,右侧只是适合它的文本表示,不一定表示全等关系,所以用 =~ 表示。而 ? 表示疑问,不确定这个值是否正确,需要作进一步判断。在单参数下,就是靠用户(程序员)人工核对。

COUT 断言成功

双参数 COUT 断言宏成功的话,与单参数输出极为相似。如 COUT(sizeof(int), 4)COUT(sizeof(int) == 4, true) 可能分别输出:

|| sizeof(int) =~? 4 [OK]
|| sizeof(int) == 4 =~? true [OK]

主要区别就是由程序帮助核对打印的值是否正确(与第二个参数比较),正确时在末尾打上标签 [OK]

COUT 断言失败

在双参数 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 定义的大括号内,其函数名是基于测试用例名自动生成的,但是也可以用在其他自由函数内。

DESC 补充描叙

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 升级为双参数断言,从手动测试转向自动测试。

用例耗时与性能测试

TIC TOC 重设计时范围

前文提到,每个测试用例输出的尾行,会打印该用例的耗时(微秒)。根据该时间报告可大致评估测试用例的运行效率。

在测试用例函数体中,还可以使用两个宏 TIME_TICTIME_TOC ,限定为该用例运行的计时范围。默认省略的情况下,TIME_TIC 相当于写在左大括号之后,而 TIME_TOC 相当于写在于右大括号之前。可以根据实际所需显式调整计时起点或(与)终点。例如:

DEF_TAST(test, "...")
{ //< A
  ...
  TIME_TIC; //< a
  ...
  TIME_TOC; //< b
  ...
} //< B

如果没有使用 TIME_TICTIME_TOC ,则计时范围为 AB ;如果使用这两个宏,则计时范围为 ab ;也可能只使用一个宏 TIME_TIC ,则计时范围为 aB ;或者只使用了 TIME_TOC ,则计时范围为 Ab

理论上,在函数体内也可以使用多次 TIME_TIC ,则以最后一次为准;如果使用多次 TIME_TOC ,则以第一次为准。也可认为左右大括号隐含写了个 TIME_TICTIME_TOC,所以大部分情况下可能都没必要显式使用,更没必要将问题复杂化多次使用。

测试用例平均耗时

函数运行时间受系统环境影响在一定范围内有浮动误差,所以如果想要更准确地评估运行时间,可采用多次运行取平均的方法。所以宏 TIME_TAST 就是设计为方便实现该功能,它可接收 1 至 3 个参数,只有第一个参数用例名是必须的,后面两个有默认值:

  • name: 测试用例名,即由 DEF_TAST (或 DEF_TOOL)定义的名称;或自由定义的 void 函数名(无参数与返回值)。
  • times: 重复运行次数,默认 10 次。
  • msleep: 每次运行暂停的时间,默认 1000 毫秒,即 1 秒。如果该参数大于 0 ,则还会打印每次运行的时间,除非指定了命令行参数 --cout=silent
  • return: 返回值就是平均时间,单位是微秒。

至于为什么要在每次重复运行之间默认暂停一秒,是为了消除时间局部性缓存的影响,相当于是测试冷启动的时间。用户可根据自己的需求决定是否要给第三参数传非 0 值。

被测的函数体,或测试用例的函数体,也可以用 TIME_TICTIME_TOC 重设计时范围。

由于测时语句总体耗时较长,且非功能测试,所以 TIME_TAST 语句本身,适合于放在另一个 DEF_TOOL 定义的测试用例内,仅在需要时显式指定参数运行。也可以将功能测试与(时耗)性能测试分开编译,物理隔离。

即使用上了多次统计平均,函数运行耗时还是与机器配置有关,因而所得的绝对值结果也可能没有普遍意义。但是可以利用 TIME_TAST 的返回值,比较两个算法实现的相对耗时大小,也就能期望验证一种算法比另一种更快或更慢。