C/C++怎么做好单元测试

什么是单元测试

单元测试是软件开发过程中的一种质量保证手段。最初的来源是想模仿对硬件芯片做单元测试那样,在软件中也能对小的软件单元进行测试,从而保证软件中某个局部设计的正确性。

传统的单元测试定义

传统软件单元测试将被测单元的粒度规定为软件中最小的功能模块。对于C语言通常指一个函数,对于Java或者C++语言通常指一个类。

传统做法是针对被测单元的实现细节进行各种白盒测试,即针对被测代码的实现逻辑进行各种分支测试和覆盖测试。

传统的单元测试由于缺乏自动化工具的支持,往往在测试中通过打印输出测试结果,由人工比对每次测试是否成功。

现代单元测试定义

随着技术的进步和人们对软件单元测试方法的发展,现代单元测试的定义已经发生了很大的变化。

  • 单元测试的粒度以软件设计的松耦合边界为粒度,不一定非要局限于函数和类这么小的粒度。例如对C++的类只用对public的接口进行测试,private接口不测试。对于C语言可以只测试每个文件对外提供服务的接口,文件内的私有辅助函数可以不测试。关于测哪些不测试哪些,最终遵循的原则是在降低单元测试成本的情况下让收益最大化。

  • 单元测试最好是针对被测单元的黑盒测试,这样降低由于被测代码实现细节的改动,导致单元测试也联动修改的频率。

  • 借助现代化单元测试框架的帮助,单元测试可做到一键式的可反复的自动化运行。用例执行结果的成功或失败完全由计算机来进行判断,无需人工参与。由于借助现代化单元测试框架,因此用例的编写需要遵循测试框架的要求。

总结一下:我们认为现代化的单元测试的定义应该是:一种满足一键式全自动化运行的软件单元级别的黑盒测试。

单元测试的价值

我们认为遵循现代单元测试最佳实践的单元测试过程,可以为软件团队带来如下价值:

  • 单元测试可以让软件故障尽早地被发现。按照统计,软件故障发现越晚,成本呈指数趋势上升。良好的单元测试让故障第一时间被发现,避免故障遗留到后期由于定位修复难度带来的更大损失。

  • 单元测试的可回归性,为软件提供了一层安全防护网。这层安全防护网为软件后续的重构和修改提供了安全保障。

  • 单元测试为软件单元如何被使用,天然提供了一份代码样例式的使用手册文档。

  • 如果能以测试驱动开发(Test Driven Development,简称TDD)的方式进行单元测试,那么可以把单元测试变成一种设计行为,可以驱动出更松耦合的代码设计和实现。

单元测试要求

我们认为合格的单元测试应该满足以下要求:

  • 测试用例要能够一键式的自动化运行和自动化的结果判断;
  • 测试用例之间不能相互依赖和干扰,也就是说每个用例可以独立地运行;
  • 测试用例是可重现的,也就是说在被测代码不变的情况下,测试用例的执行结果应该是一致的。测试不应该依赖不稳定的因素:例如定时器、线程调度等等;
  • 测试用例应该是简单易于理解的,测试用例要追求可读性,这样才能把测试用例同时作为一份接口使用文档;

单元测试工具

随着技术的成熟,单元测试工具现在已经变得很容易获得和使用了。自从Kent Beck(敏捷软件开发方法泰斗,极限编程和测试驱动开发的提出者)为Java语言开发并开源了JUnit框架后,一下子将单元测试带到了一个新的境地。随后其它语言纷纷效仿JUnit推出了自己的开源单元测试框架。人们后来对所有编程语言的这一系列框架起了个统一的名称,叫做xUnit测试框架

评判xUnit测试框架的标准

目前对于任一编程语言,都能找到好几款开源的的xUnit测试框架,那么如何对比并选择合适好用的xUnit框架呢?一般从如下几个维度去评估。

  • 支持自动检测注册用例:框架能否支持简单地构造用例并自动注册测试用例到测试框架中;
  • 支持测试Fixture:即是否支持为一组测试用例建立统一的脚手架,方便测试用例的上下文构造;
  • 强大的断言系统:是否提供强大的断言系统,供使用者在用例中描述期望;
  • 灵活的Test Suite定义:可以支持灵活的对测试用例分组;
  • 测试能力:是否支持异常测试以及参数测试;
  • 测试filter定义:可以支持灵活的命令行参数,对运行用例进行分组和过滤;
  • 测试结果及报表生成:是否可以生成易于阅读的测试结果报告以及报表文件;
  • 用例依赖管理:是否支持编辑用例的依赖关系,让用例之间互相组合,但是又不破坏每个用例的独立性;
  • 沙盒模式:是否支持测试用例的沙盒模式,降低每个测试用例上下文清理的工作;
  • 是否开源,包括公开的文档和社区的支持是否全面;

主流C/C++ xUnit测试框架对比

根据上面提到的判断维度,我们分析对比一下当前主流的C/C++ xUnit测试框架。

测试框架特性 Boost Test CppUnit Gtest TestNgpp
是否开源
自动检测注册
断言能力 较弱
支持Fixture 支持 支持 支持 支持
支持Suite分组 支持 支持 支持 支持
支持用例过滤 支持 支持 支持 支持
测试报表 不支持 支持 支持 支持
测试能力
用例依赖管理 不支持 不支持 不支持 支持
沙盒模式 不支持 不支持 不支持 支持
社区使用程度 一般 使用程度很高 一般

通过上面的分析可以看到,主流的C++ xUnit测试框架都是开源的。其中TestNgpp功能虽然最强大,但是用户较少。Google推出的Gtest框架使用范围最广,社区支持程度也最好,从功能上来说简单易用,作为上手框架最为合适。其它框架由于各种缺陷不建议再选用了。

Mock框架推荐

在做单元测试的时候避免不了要为被测代码打桩,而mock框架主要是为了简化打桩过程。使用mock框架可以让打桩代码非常容易撰写,而且不会侵入实现代码。比如两个测试用例需要同一个桩函数:函数声明相同但是返回值不同。在没有mock框架的情况下解决这类问题非常麻烦,而mock框架则可以轻而易举的应对此类问题。

Mock框架除了提供打桩的功能外,还提供其它更加强大的功能。例如何以监听用户对打桩代码的调用行为,并监控这些行为是否符合预期。

对于Java语言来说,可用的mock框架五花八门,选择范围非常广。但是对于C++语言来说,只有两款易用的mock框架:gmock和mockcpp。这两款都是开源软件,经过使用对比,mockcpp功能强大且用户体验胜过gmock,所以基本没有什么好对比和推荐的,如果需要直接上mockcpp就好了。

单元测试过程

基于前面介绍的xUnit测试框架,为代码做单元测试的过程一般分为如下主要步骤:

  • 单元测试环境搭建;
  • 单元测试编写、运行,测试通过后将代码合入代码管理仓库(GIT或SVN);
  • 持续集成服务器根据规则统一运行所有已入库的单元测试用例;

单元测试环境搭建

这一步是在每个开发人员的机器上搭建单元测试环境。需要做的步骤如下:

  • 下载gtest和mockcpp源码,按照gtest和mockcpp的构建安装手册,进行编译安装;
  • 针对当前项目的构建工具链和目录结构,为单元测试编写一个构建脚本。该脚本要能做到把被测的代码和gtest、mockcpp以及测试用例代码编译构建成一个软件程序。该脚本需要能够一键式地进行编译、链接以及执行生成的软件程序;
  • 环境搭建好之后,写一些简单的例子试运行一下,确定环境安装OK;

测试编写过程

当开发人员的机器上已经搭建好单元测试工具后。接下来就可以对代码进行单元测试了。

一般使用xUnit框架进行单元测试主要有以下几个过程:

  • 建立一个单元测试的代码文件,如果是C++的话,那就是一个普通的cpp源码文件;

  • 选择需要测试的对象代码,例如某个接口函数或者某个类。在测试文件中包含待测代码的头文件。

  • 在测试文件里编写测试用例,测试用例一般包含以下几个主要部分:

    1. 为待测代码准备上下文环境。一般就是准备待测代码可以被调用的初始条件,例如准备参数、创建类的对象等等;
    2. 调用被测代码的接口,传入对应准备好的参数;
    3. 根据可观察的返回编写断言,描述期望中应当正确发生的事情。例如接口应该的返回值是什么,或者某一资源应该发生的变化结果等等。
    4. 清理上下文。一般是把为该测试准备的上下文清理掉,这样做主要是为了每个测试的独立性和不互相干扰,避免下个测试受前一个测试的上下文影响。
  • 编写好用例后,调用测试用例的构建脚本,编译及执行用例,看用例是否通过。

  • 如果用例失败看是用例的问题还是被测代码的问题,修复直到用例通过。

  • 将编写好的用例以及修改的代码提交到代码管理仓库。

通过持续集成进行部署

一般一个大型的软件团队都是多人合作开发的模式,这时会通过公共的代码管理仓库进行协调。项了保证代码每次修改的安全性,需要搭建持续集成服务器。持续集成服务器就是安装了持续集成软件(例如开源的Jenkins软件)的机器。该机器会实时监控代码管理仓库,一旦发现有新的代码提交,就会触发一系列用户定义的持续集成任务(参加下面的示意图)。

以Jenkins举例来说,常见的可配置的持续集成任务包括:

  • 编译构建;
  • PCLint检查;
  • 运行所有单元测试;
  • 代码测试覆盖率报表生成;
  • 运行其它自动化的测试用例:例如组件测试或者系统测试;

由于持续集成服务器时刻监控代码管理仓库,一旦有新的代码合入就立即执行对应的任务:例如编译、构建、执行所有单元测试用例等等。持续集成工具都支持结果通知的配置,当存在某项任务失败则通过看板或者邮件的方式通知指定负责人,这样一旦有人提交的代码造成编译构建失败或者单元测试失败,就会立即被发现。这样就避免了低质量的软件合入到代码仓库后,到很晚才能知道的问题。

测试覆盖率统计

和单元测试相关性较大的一个是测试覆盖率报表的生成。对于C/C++,可选的测试覆盖率工具并不多,见下表。

工具 平台 是否开源
Coverage Validator windows 商用
OpenCppCoverage windows 开源 (只支持VS2013以上版本)
gcov + lcov linux 开源

测试覆盖率工具一般安装部署在持续集成对应的机器上,这样每次持续集成服务器跑完测试用例后,就会根据当前的测试运行情况自动计算出所有代码的测试覆盖率结果,可以详细看到每一行代码的的覆盖情况。生成的报表可以自动发布成一个网页,项目中的所有人都可以看到。

单元测试注意事项

前面我们介绍了单元测试的工具和实施过程,接下来我们看看做好单元测试要注意的一些事项。

常见误区

在实践的过程中,发现经常有团队虽然开发了大量的单元测试,但是单元测试有效性却很低,付出了大量成本却并没有得到单元测试的收益。总结之后主要有以下一些原因:

  • 异常测试覆盖不足;我们不需要对被测对象的所有可能输入都做测试,但是需要对其做等价类划分,对于每种等价类至少需要一条测试。常见的错误做法是永远只测试正常场景,对异常场景测试的很少。

  • 测试缺少断言;每个测试结束后需要用断言来设置正确的预期结果。如果断言没有写全,那么必然遗漏了重要的检查点,就相当于给安全网撕了个口子。见过一些极致的场景,开发人员为了完成测试用例指标而去凑测试用例数,所有用例不加断言。这样虽然看到执行通过的测试用例很多,测试覆盖率也很好,但是全是无效用例。

  • 测试设计能力不足,测试覆盖没有规划。理想的情况下应该每个测试对被测代码的覆盖是正交的,每个测试用例覆盖产品代码的一部分,整体上防护全部。这需要有顶层的测试设计,尤其是对于后补的单元测试,顶层的测试设计可以规划优先级和从重点区域开始覆盖。常见的误区是开发人员各自加单元测试,但是遗漏了对重要区域的覆盖。

  • 产品代码设计问题,物理或者逻辑依赖太复杂,导致单元测试很难写。这时需要对原有代码边重构边补充单元测试。所以说单元测试能否搞好,不仅仅是测试的问题,即使不采用TDD的方式也得对产品代码中的不合理设计做优化,才能让单元测试更有效。

如何降低单元测试成本

从长期来看,降低单元测试的成本并不在于使用了更好的单元测试工具,而在于降低由于被测代码改动导致单元测试随之变动的频度。软件之所以和硬件不同就在于它的软,它的存在价值就是为了应对变化。而软件的变化性往往越向内传递越剧烈,这导致了软件单元级别的设计经常处于变更的核心旋涡之中。所以经常见到有些软件团队,一旦需求变化快工期紧,很快就把单元测试抛弃掉了。被测代码变化导致单元测试跟着变化不可能不发生,但是我们要通过设计降低这种联动变化的概率,这样才能降低单元测试的维护成本。

要让单元测试能够以较低成本维护,需要注意一下事项:

  • 单元测试尽可能是单元级别的黑盒测试。白盒测试和代码实现细节耦合大,一旦代码修改测试就要跟着改,造成重复工作量。
  • 单元测试前先要理清楚被测对象的耦合关系。如果被测对象的耦合关系复杂,那么测试用例需要模拟被测对象的所有耦合关系,这样一旦被测对象的依赖关系发生变化,测试也要跟着一起改。这时最好是先对被测代码做一定的解耦重构工作。
  • 如果可能尽量学习并掌握TDD的做法,对新开发的代码尝试采用TDD,让单元测试驱动出更好的代码实现,反过来也驱动出了相对更稳定的测试用例。
  • 开发人员的代码设计能力决定了单元测试的根本质量,需要持续地提高开发人员的软件编码能力。

由上可见,做好单元测试不只是掌握单元测试工具的使用就万事大吉了。需要对开发人员的能力进行提升,主要包括:

  • 如何合理设计软件,对软件单元进行划分,设计低耦合系统;
  • 如何设计出靠近黑盒级别的自动化单元测试用例;
  • 如何对遗留系统进行解耦重构的能力;
  • 掌握并实施TDD的能力;
  • 设计合理的持续集成策略;

将单元测试与整体测试策略结合

单元测试只是软件测试策略中的一个环节,其它的还有系统测试,集成测试,组件测试等。每一级的测试都有其价值和不足,所以整体测试策略需要关注如何把这些测试策略整合起来,让整体的成本收益率最好。所以从根本上需要站在全局规划整体的测试策略,这块可以参考敏捷测试的测试象限和金字塔模型理论,然后根据项目的实际情况制定合理的整体测试策略。

相关推荐材料和培训

  1. 最全的xUnit开源测试框架列表,针对每种编程语言:https://en.wikipedia.org/wiki/List_of_unit_testing_frameworks
  2. xUnit测试最佳实践:《xUnit Test Patterns》可是说是最权威的一本书。
  3. 测试策略规划:《敏捷软件测试》,测试象限和测试金字塔理论。
  4. 《TDD in Embeded C》:嵌入式环境的TDD实践指导。