关于如何写好单元测试的思考

"二八定律",由19世纪末20世纪初意大利经济学家巴莱多提出。他认为,在任何一组东西中,最重要的只占其中一小部分,约20%,其余80%尽管是多数,却是次要的。

What?

释义

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。这里的最小可测试单元并没有一个明确的标准,在C++语言中,我们可以认为是一个接口,无论是类接口或者是全局函数。

借用《测试驱动开发》里面的设想,如果把编程看成是转动曲柄从井里面提一桶水的过程,测试程序就是一个防倒转的装置,让我们可以转一会儿休息一会儿。水桶越大,防倒转装置的棘齿就越相近。

目的

单元测试的目的是确保每个单元都能独立的正常工作,从而提升整个程序的质量、可靠性和可维护性。

设想一下,如果把编写程序类比成一个搭积木的过程,每个模块就是一个一个的小积木。在拼装积木之前,我们要保证每个积木的质量都经过了严格的测试。

测试框架

单元测试的本质其实就是通过写测试代码来测试被测试的代码。在实际执行过程中,我们可能需要很多辅助类功能,形如用例管理、用例组织、测试断言、显示测试进度、友好的测试信息输出以及其他高级的用法。

以测试一个加法函数为例:

c++ 复制代码
#define CheckEqual(X, Y) \
if (X == Y) std::cout<<"check " << #X << " == " << #Y << " success."<<std::endl; \
else std::cout<<"check " << #X << " == " << #Y << " failed."<<std::endl;

int32_t AddFunc(int16_t param1, int16_t param2){ return (param1 + param2);}

CheckEqual(AddFunc(9, 10), 19);
CheckEqual(AddFunc(9, 10), 20);

运行上面的代码,我们可得到下面的控制台输出。

c++ 复制代码
check AddFunc(9, 10) == 19 success.
check AddFunc(9, 10) == 20 failed.

这样我们就实现了一个最简单的测试断言。

所以测试框架只是在一定程度上封装了一系列有助于我们编写测试代码的功能和方法,提高我们编写测试代码的效率。理解了这一点,将有助于我们更好的理解和使用测试框架。

Why?

为什么要写单元测试,我们可以轻而易举的在搜索引擎或者相关专业书籍上面看到如下答案:提高代码质量和可靠性、支持代码重构、减少调试时间、提高开发效率等等,这些说法虽然正确,但未免显得过于专业和官方,缺乏一定的说服力。下面我想谈几点自己的理解。

即时反馈

玩过英雄联盟的人会感受到,在游戏过程中什么时间段最爽?就是当你干掉对方英雄时,听到随之而来的"First Blood","Double Kill"、"Killing Spree"等等。这种对你的行为立马给予的评价就是即时反馈。如果用一句话来概括,即时反馈就是一种"用来表明我们的行为正在导向目标和成功的信号",这种信号既可以来自于内在的自我,也可以来自外部评价。同样,单元测试也可以给编码者提供及时反馈。

相信大家在编程入门时,最爽的时刻肯定是Main函数运行成功并在屏显上打印"Hello World!"的时刻。那么在实际开发过程中,基于良好的构建工程和测试用例,我们是可以做到一边编码一边运行单元测试。相信在编码过程中不断地看到编译"0 warnning, 0 error",单元测试运行"0 error",内心也是暗爽的。

问题前置

这里主要是想讨论解决问题的成本。当问题发生在不同的时机,其解决问题的成本是不同的,甚至是呈指数级增大。考虑一个"处理委托时未把关键字段正确处理,导致影响正常交易"的问题:

  1. 发生在编码阶段,正式送测之前:
    解决成本:编码 + 自测,无人知道,花费时间:十分钟
  2. 发生在内部测试阶段,正式送测之后:
    解决成本:测试支持 + 问题确认 + 问题修复 + 重新送测,测试知晓且计入开发个人bug列表,影响绩效,花费时间:四小时
  3. 发生在客户测试阶段,正式上线之前:
    解决成本:问题排查 + 重新规划版本,拉通产品 + 测试 + 交付,花费时间:一天
  4. 发生在生产环境:
    解决成本:生产应急 + 问题排查 + 事故报告 + 问题复盘, 影响公司信誉,花费时间:三天~五天不等
    所以,同样的一个问题出现在不同的阶段,其解决成本存在天壤之别。 我们不怕出现问题,天底下没有任何一个程序员可以做到编码 0 Bug,但是我们可以前置大部分问题发现的时机。 通过单元测试在配合每日自动化执行(或者CI检查流程),可以做到提前发现并解决大部分的问题

支持代码重构

事不过三,三则重构

很多人在没有正式接触重构思想之前,可能会存在以下的认知误区"运行好好的代码,我干嘛要重构它?"

重构并不是说我要将以前的实现全部推翻重写,也不是一件应该特地拨出一段时间来做的事情。重构不是目的,而是一种帮助你把事情做好的手段。

什么是重构?摘自《重构 - 改善既有代码的设计》里面的描述:不改变软件可观察行为的前提下,改善其内部结构以提高理解性和降低修改成本。

随着业务的变更迭代,代码也随着变的越来越糟。这就是常说的代码坏味道:

代码坏味道

  • 重复代码(duplicated code)
    同样的代码出现在多个地方,增加了维护成本,容易导致不一致性
  • 长方法(long method)
    方法过于庞大,难以理解和维护,通常需要拆分为更小的方法
  • 过长参数列表(long parameter list)
    方法的参数列表过长,不仅难以调用,还容易引发错误
  • 紧密耦合的类(tight coupling)
    类之间的依赖关系过于紧密,改动一个类可能影响多个类,降低了灵活性
  • 冗余代码(dead code)
    不再使用的代码片段,应该及时删除以保持代码清洁
  • 全局状态(global state)
    过多的全局变量和状态共享使得代码难以测试和理解
  • 代码注释(excessive comments)
    过多的注释通常表示代码不够清晰,需要改进

营地法则

我们应该至少在离开营地时,让营地比我们到来时更干净

这句话的意思是每次在变更一段代码时,至少不让代码变得比我刚开始改动时更糟糕(参考上面提到的坏味道)。如果每次经过一段代码,都让其变得更干净,积少成多,垃圾都会被清理掉。

所以我们每个人都应该在提交代码时停下来想一下,我的这次提交是让代码更健康了,还是更糟糕了,还是没有变化?一个糟糕的例子就是我们增加了一段重复代码,而一个健康的例子就是在增加功能的同时,顺便重构了之前的代码,让其可读性更高,复用性更强。

如果我们每个人都能做到营地法则,那至少能够让向坏方向旋转的齿轮停下来。

那为什么说单元测试能够支持代码重构呢?在重构过程中肯定会有这样的担忧"重构的风险太大,担心引入新的bug",如果没有自测试的代码,那么存在这种担忧就是完全合理的。但如果有一套完备的测试套件,就可以保证代码随时处于一个健康的状态。

小范围的重构完,跑一遍单元测试,如果单元测试都通过,那至少说明我们的重构没有破坏原有代码逻辑的正确性。不过这里的前提是得保证单元测试存在一个合理的覆盖率和覆盖范围;如果整个单元测试就零星几条用例,那运行是否通过对于检查重构是否成功不具备任何参考意义。

改进设计

在讲软件架构设计原则时,经常提到一句话叫"高内聚,低耦合"。一个好的设计一定是低耦合的,这样可以延迟决策,降低决策成本,也可以并行开发,提高代码开发效率。然而除了设计(代码)评审以外,并没有一个很好的方法来促成良好的设计。

单元测试可以很好的起到这样的作用,一旦耦合度过高,那么在执行case,输入数据时就会变得异常困难。这样的问题会反过来促进我们在编码时尽可能的不依赖,或者合理依赖。

考虑以下场景:
A模块存在参数P,而参数P存在于B组件下发的C文件当中

如果我们采取以下的设计:

  • A模块在启动时加载并解析文件C,将解析到的结果作用于参数P

那么我们在测试A模块时,就需要造一份符合测试要求的文件C,供A模块加载完之后进行测试。如果B组件是跨组维护,且文件C时二进制数据文件且没有文档描述。那么造文件C就会变成一件耗费人力和时间成本的事情。

改进设计:

  • A模块提供参数P的公有Set方法,供参数加载模块加载并解析C文件之后调用

那么我们在测试A模块时,造数据就变得异常简单。只需要在测试代码中调用一行Set方法即可。参数加载模块应该有其单独的测试用例,在这个用例中才应该关心C文件的内容和结构。那有的读者心里可能会问:"那不是同样还需要关注C文件吗?",虽然是这样,但这种做法既简洁了架构设计,做到单一职责。今后B组件导致C文件的变更不会影响到A模块。同时也可以提高开发效率,在人力允许的情况下,A模块和参数加载模块可以并行开发,完全不耦合。

熟悉代码

单元测试不仅起到了测试的作用,在一定程度上还是一种很好的"文档"。通过阅读单元测试代码,我们可以不需要深入的阅读代码实现,就能知道这段代码的作用和用法;同样,给新人安排完善单元测试用例的工作任务,也是一种很好的学习入门手段。

How?

二八原则

正如引言里面所描述的那样,在一个项目中重要的部分只占20%,其实就是告诉我们做事情要抓重点。应用到软件测试里面就是:80%错误是由20%的模块引起的。简单、容易的模块或功能是很少引入过多Bug的,而对于复杂逻辑的关键模块往往会引起系统80%的错误。只有关键模块稳定了,整个系统才可能真正的健壮和稳定。

写单元测试时,我们要考虑一个问题:单元测试到底要写多细,一昧的追求单元测试覆盖率到底有没有意义?

StackOverflow上面有一个讨论 How deep are your unit tests?

这个问题是:

The thing I've found about TDD is that its takes time to get your tests set up and being naturally lazy I always want to write as little code as possible. The first thing I seem do is test my constructor has set all the properties but is this overkill?

My question is to what level of granularity do you write you unit tests at?

...and is there a case of testing too much?

点赞最多的答案是:

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence (I suspect this level of confidence is high compared to industry standards, but that could just be hubris). If I don't typically make a kind of mistake (like setting the wrong variables in a constructor), I don't test for it. I do tend to make sense of test errors, so I'm extra careful when I have logic with complicated conditionals. When coding on a team, I modify my strategy to carefully test code that we, collectively, tend to get wrong.

"如果我通常不会犯错误(比如在构造函数中设置错误的变量),我就不会测试它。我确实倾向于理解测试错误,所以当我有复杂条件的逻辑时,我会格外小心。"

所以单元测试并不是越多越好,也不是越细越好,最重要的是要识别出哪些代码需要测试覆盖:

1、逻辑复杂的

2、容易出错的

3、不容易理解的

4、公共代码

5、核心业务代码

这里面我认为最重要的就是需要识别出什么是核心业务逻辑?考虑以下场景:

在异构对接场景中,对接模块需要将A系统的协议转成B系统的协议之后发送给B系统,反之亦然;

那在这样的场景中,核心业务逻辑就是字段转换。用户只关心你的字段转换对不对,不关心其他细节。那么这里的转换逻辑我们就需要进行单元测试覆盖,并且要对所有的case进行检查。

以市场字段转换为例:

c++ 复制代码
/**
 * @brief MarketID转换
 * @param market_id[in] 源市场字段
 * @return market_id      目的市场字段
 */
uint16_t GetMarketID(uint8_t market_id) 
    throw(std::invalid_argument);

形如以上的声明,我们应该有如下的测试代码

c++ 复制代码
CHECK_EQUAL(GetMarketID(1), 101);
CHECK_EQUAL(GetMarketID(2), 102);
CHECK_EQUAL(GetMarketID(3), 103);   // 检查所有的转换case
CHECK_THROW(GetMarketID(99), std::invalid_argument);  // 检查异常case

Mock测试

mock的字段释义是模拟,是指在测试过程中对于一些不容易获取/构造的对象,创造一个mock对象来模拟对象的行为。mock是为了解决不同模块/单元之间由于耦合而难于开发测试的问题,所以不光是单元测试,mock也会出现在组件测试或者集成测试中。

比如说A模块依赖B模块,但是B模块还没开发完成或者是由其他人开发,那么你就可以创造一个mock的B对象,并按照预期返回对应结果供A模块调用。

根据我的实际使用来看,Mock有两种实现模式:

  • 重写虚函数
    这也是turtle库里面使用的模式,其原理就是通过turtle库实现一个被依赖模块的子类供测试类调用;这里就要求至少在测试代码编译时,被依赖模块的接口是虚函数
  • 重写cpp实现
    除了上面的虚函数重写模式,在测试代码中也可以重写被依赖模块的实现,通过调整编译依赖在测试代码构建时依赖新实现,而不去依赖原始工程里面的具体实现; 这种模式对源码依赖及构建工程的要求比较高,一般我们推荐使用第一种模式。
    基于以上,在源代码的设计上就存在以下几个要求:
  1. 尽量少的使用单例模式对外提供服务,除非被依赖类完全不需要mock; 比如无状态的Util工具类等
  2. 尽量不要在模块内部实例化被依赖类,可以由外界实例化之后再传入
  3. 高内聚,低耦合。非本模块关心的事情,不应交由本模块处理
  4. 不要使接口存在未明确的行为,这会导致无法测试

编写时机

即什么时候开始写单元测试?

  1. 常规做法是写完业务代码之后再写测试用例,也就是我们常说的"补"用例。其实我不太喜欢"补"这个词,因为它意味着测试代码是附属品,或者是为了完成某一个要求而必须实现的内容。而没有把它摆在和业务代码一样重要的地位来看待。
  2. TDD(测试驱动开发)推崇在具体实现代码之前开始,也就是先写测试代码,再写业务代码。个人看来,这种模式很适用于由优秀的架构师定义接口行为,再由程序员实现具体细节的协作开发场景。因为接口行为明确了,实际上具体的输入输出case也就明确了。
  3. 另外一种做法就是与具体实现代码同步进行。先写部分实现代码,紧接着再写单元测试。重复这两个过程直至完成整个功能的开发。但实现效果与第二种一样,功能实现完,测试代码也写完了;
    在这里我建议实行第三种模式,这里和之前提到的"及时反馈"是呼应的。我们平常编码时也会不断地执行编码、编译这两步,检查自己的编码是否存在编译错误。这也是一种寻求自我反馈和自我检查的行为,同样在编码过程中不断地执行编码、编译、运行单元测试能得到更多的良性反馈。在合适的场景下,可以尝试实行第二种模式,虽然这是一种很理想化的模式;
    但强烈不建议使用第一种模式,尤其是在时间紧任务重的项目中,很大一种可能就是代码合并送测完之后就不会再管了,留下的只是一堆未经过自测试的代码。

积木原则

二八原则能够很好的阐述"单元测试究竟要写多细?"这个问题,即重要复杂的模块多写,不重要简单的模块少写;但是它只能作为指导思想来描述大体的测试方向。

在具体编写用例时,可以考虑将待测试的模块想象成一个个的积木,整个软件是由不同的积木拼装起来。单元测试要做的事情就是保证每个积木的质量完备性,即需要针对待测试接口的每个case进行测试,尤其是各种异常边界条件的检查。

假想一下,如果一个软件有三块很重要的积木,每个积木有10种输入输出,如果把它组合起来,那就有1010 10=1000种输出输出,如果功能测试要将所有场景全部覆盖,那将是指数级别的用例数量。如果在单元测试里面写用例,那只需要10 + 10 + 10 = 30条用例就完成了所有输入输出的检查。

所以这也是为什么在组件测试中不推荐有过多边界条件检查用例的原因,常规来说只要覆盖最常见的生产场景即可,即正常的输出输出;因为组件测试更多的是集成测试,检查将组件内部各个模块串起来能否正常工作。

拒绝源码侵入

尽量不要为了运行单元测试,而去修改代码实际的运行路径;如果代码中出现了形如

c++ 复制代码
#ifdef UNITTEST
// dosth.
#else 
// dosth.

这样的代码,那应该思考是否违背了某些设计原则,导致代码不可测试,考虑重构这段代码使其可被测试。

如果是通过宏定义控制其虚函数属性,为了达到被mock的目的,是可以接受的;因为修改虚函数属性本质上没有改变代码的实际运行路径。

但如果通过宏定义修改其公有/私有属性,其实也是不被提倡的; 因为在类设计时,如果一个资源被声明为私有,那么就说明它是被隐藏起来的实现细节,不希望被外界所关注,同时也代表着它是易变的。可能会随着需求的变更调整其实现细节;所以在测试时,应该要从测试单元的可观察行为来出发。

控制用例执行时间

单元测试执行要快,所有的用例运行时间应该要控制在秒级别,而不是几分钟;只有快,才能保证测试效率,才能保证"及时反馈"。如果用例执行时间太久,对程序员的耐心是一个不小的消耗。

在一定程度上,程序员就是单元测试的用户,如果单次时间太久,久而久之势必会影响到程序员对单元测试的热情。

结语

借用《架构整洁之道》里面的一句话:软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求

同样,我们写单元测试也是一样的目的:通过发现错误,改进设计的做法来达到提高程序质量和可维护性的目的

参考书籍

《架构整洁之道》-罗伯特·C·马丁 Robert C. Martin

《重构:改善既有代码的设计》第二版 -马丁·福勒 Martin Fowler

《测试驱动开发》-肯特·贝克 Kent Beck

相关推荐
FeboReigns1 小时前
C++简明教程(4)(Hello World)
c语言·c++
FeboReigns1 小时前
C++简明教程(10)(初识类)
c语言·开发语言·c++
zh路西法1 小时前
【C++决策和状态管理】从状态模式,有限状态机,行为树到决策树(二):从FSM开始的2D游戏角色操控底层源码编写
c++·游戏·unity·设计模式·状态模式
.Vcoistnt2 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
小k_不小2 小时前
C++面试八股文:指针与引用的区别
c++·面试
沐泽Mu2 小时前
嵌入式学习-QT-Day07
c++·qt·学习·命令模式
ALISHENGYA2 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战训练三)
数据结构·c++·算法·图论
GOATLong3 小时前
c++智能指针
开发语言·c++
F-2H4 小时前
C语言:指针3(函数指针与指针函数)
linux·c语言·开发语言·c++