couttast: 轻量级单元测试扩展静态库演化思路

七阶子
34 min, 6602 words

归类: 程序设计

couttast: 轻量级单元测试扩展静态库演化思路

作为一名 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 复杂多少。而 COUTDEF_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() 中拆解子函数的代价交换有关, 毕竟所有代码写在一起,很多初始化动作就天然地只要写一次。

最后,写单元测试代码时,主要谨记一个简单性原则,一个用例内以顺序结构为主,最好 不要有分支与循环,除调用被测函数外,其他辅助测试函数的调用链不要太深。这才能达 到以简驭繁的效果。

三、单元测试用例运行与管理

写完单元测试用例,下一步就是编译、链接生成可执行测试程序,并让它跑起来。用 couttast 库编写的单元测试程序,它就是一个普通的命令行程序。最简单的运行方式 就是不带任何参数运行,它就会按一定顺序依次执行测试源码中用 DEF_TAST 定义的用 例。如果 main() 转发 RUN_TAST 返回值是 0 ,表示失败用例数为 0 ,则程序 退出码也是 0 ,也就是所有测试通过。

在很多正常情况下,比如集成到自动化流程后,这个默认行为也就够了,很平淡无奇。但 在某些情况下,可能就要有选择性地执行某个或某几个测试用例了。比如自动化测试报告 某个测试用例失败了,那就要单独拎出这个测试用例来,在个人的开发环境中重新跑一遍 ,排查问题。又比如在开发过程中,不想每次全量跑所有测试用例,那可能耗时比较长, 只想跑自己新加的几个测试用例。

所以,我认为作为内含许多测试用例的命令行程序,它的命令行参数最重要的作用就是指 定运行哪个或哪些测试用例。在 couttast 中,对命令行位置参数就是这么解释的。位 置参数就是不带 -- 前导的纯参数,带 -- 的参数(对)也经常叫选项。此外,为了 方便用户,couttast 不要求输入测试用例的全名,可以只输入部分字符串,测试用例 中包含这个子串的视为匹配用例,会被执行。

我对 gTest 的一个痛点记忆,就是要指定运行某个(某些)测试用例比较麻烦, --gtest_filter 选项参数格式还挺复杂。而且测试用例是按随机顺序执行的,它的 随机哲学是想保证测试用例的独立性,避免特定执行次序的依赖性。初看起来这没什么毛 病,但这个组合王炸就曾在我当年工作中造成很大麻烦。

事情是这样的,有 n 个单元测试用例的测试程序,不带参数默认跑,会报一两个不通 过的失败用例,但单独跑那个失败用例却又能通过。那原因应该是某些用例互相影响了, 关键是如何更方便地找出互有影响的用例。程序员容易想到用二分法找问题。假设 n=10 ,第 50 号用例失败了,单测 50 号没问题。那问题就在前 50 个用例,再二分 验一下 26-50 以及 1-24,50 用例。但悲剧的是 gTest 它没有很好的办法筛选出 特定的 25 个用例,而即使恰巧能通过什么通配语法筛出来,它还是随机顺序跑的,也就 意味着可能复现不了。因为原因可能是需要某两个(甚至某三个用例)按特定顺序执行才 会触发 bug 。最后我们只能采用巨麻烦的笨办法,修改 main() 函数,调用 gTest 内部的 api 加入特定的测试用例,先猜哪两个用例可能有互有影响,修改 main() 重 编译跑一下,猜错了重新修改、编译、运行……

我不知道现在 gTest 对此类问题有没更好的解决办法了,反正当年是对此事印象深刻 。若非此事,我也没足够的动力放着 gTest 不用转而自己从头造个单元测试的轮子。 如果用 couttast 遇到类似问题,只要把猜测有影响的用例名按顺序粘贴到命令行重跑 就能排查——我觉得这是很符合直觉的尝试方案。

同时我也不觉得随机顺序是重要特性。我在 couttast 内部只是采用最常用的 std::map 保存测试用例,所以恰好有序,默认就有序运行,那就让它有序,没必要费 劲特地随机化。用户难不成还能利用这“漏洞”使原来不通过的测试用例变成通过不成, 动机收益何在?以及可能反向的收益,还能期望利用随机顺序的测试用例来发现被测目标 软件(或库)的潜在 bug ,听起来也不怎么靠谱吧。

除了最重要的位置参数用于筛选指定测试用例外,couttast 也支持一些选项参数。比 如 --list 列出所有测试用例名,也是有序的,与默认运行的所有用例顺序一样。大写 的 --List 则列出更详细的用例信息,包括 DEF_TAST 定义时传入的描叙性第二参数 。这就使得 couttast 有了基本的管理用例功能,可以较方便地导出测试用例一览表。

此外,couttast 的默认输出信息可能冗余度比较大,因为我认为单元测试也是开发的 一个辅助工具,所以默认输出详尽一点。但开发与自测完成,提交到测试阶段,可能就没 必要打印太多输出了。因此有选项 --cout=[fail|silent|none] 来精减输出:其中 fail 表示 COUT 双参数断言时只打印失败的语句,不打印成功的语句;silent 输 出得比 fail 还少,但失败的语句还是有必要打印的;none 是真的什么都不打印了 ,但仍可以通过退出码零与非零来判断测试是否通过。在集成到自动化测试流程中,调用 脚本加上 --cout=silent 参数可能是比较合适的。

四、单元测试库的设计与集成

对于普通开发,对这层可不必太关注,重点应关注前两层单元测试用例的编写,以及了解 所用测试框架(或库)的使用,如命令行参数的意义与用法。

所以我也只简单谈谈开发 couttast 单元测试库过程的一些想法。缘由前面几节已有涉 及,最开始为了简单方便,就写了个单个头文件的 header-only 。刚开始了解到 C++ 的 header-only 库时,也像面向对象一样,觉得它很酷很方便。但后来又有了不同的反思, 面向对象不是唯一,header-only 库也有它的不足,对维护与使用也都有它特定的麻烦。

所以我在后来为 couttast 补充一些非核心的扩展功能时,就没再坚持 header-only ,而是写到独立的 .cpp 中编译成静态库。但核心功能还是保持在一个单头文件 tinytast.hpp 中,也能单独使用。这符合二八定律,用少量代码完成大部分功能。

所以即使是扩展了静态库,也保持了轻量与无依赖。因为我觉得单元测试库的设计与单元 测试用例的设计一样,都要保持简单。如果测试代码本身复杂了,那就增加引入 bug 的 风险,大大削减了测试的作用与意义。其他库可能是功能越强大越好,但单元测试库,可 能未必如此。

尤其是,C++ 的库依赖也一直是老大难的问题,菱形依赖更是天坑。这才是单元测试工 具库需要保持轻量无依赖的重要原因。因此,即使你觉得 couttast 不合你的口味,也 应尽量选用无依赖的库,避免后面不经意间引入菱形依赖导致库冲突的错误,那就很冤。 这种情况一旦出现,就很难排查,因为被测代码没问题,测试代码也没问题,合在一起就 出问题了。

集成是指将单元测试程序集成至其他高阶流程中,例如自动化 CICD ,自动化测试应是其 中一步。基于 linux 开发的命令行程序,都是很容易集成的。

五、可测试程序的一般原则

最后,再简单聊一点非技术的观点。

其实,单元测试本身是没有价值的,有价值的是被测程序。这是一种依附关系,正所谓皮 之不存,毛之焉附。如果被测程序本身是一坨,单元测试再怎么玩也难以屎上雕花。

所以单元测试最最重要的一点,是被测程序具有可测试性。可测性是可维护性代码的重要 质量指标。可测试性,从微观上讲也不难理解,就是不要写成一坨,需要恰当的分解,每 个子函数(类、模块)职责单一明确,有易控制的输入输出。

对于分解的粒度,我推崇一个简单粗暴的数量级划分。对于 shell ,就适合写单行命令 行,对于脚本语言,每个函数适合写 10 行数量级,而对于 C++ 语言,每个函数适合写 100 行量级,每个源文件在 1000 行左右。

对于 TDD ,测试驱动开发,我认为也只是个美好的理论,实际项目中严格遵循 TDD 未必 合适。我更推荐双向奔赴的过程,先做基本框架设计,再写单元测试用例,再完善代码设 计……如此交错,直到完成开发目标。一份良好设计的软件代码,与其单元测试代码,理 想情况下,就正如 DNA 双螺旋那样,相辅相成,互为补益纠正,共同保障软件朝正确可 控的方向演化与进化。