“二八定律”,由19世纪末20世纪初意大利经济学家巴莱多提出。他认为,在任何一组东西中,最重要的只占其中一小部分,约20%,其余80%尽管是多数,却是次要的。
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。这里的最小可测试单元并没有一个明确的标准,在C++语言中,我们可以认为是一个接口,无论是类接口或者是全局函数。
借用《测试驱动开发》里面的设想,如果把编程看成是转动曲柄从井里面提一桶水的过程,测试程序就是一个防倒转的装置,让我们可以转一会儿休息一会儿。水桶越大,防倒转装置的棘齿就越相近。
单元测试的目的是确保每个单元都能独立的正常工作,从而提升整个程序的质量、可靠性和可维护性。
设想一下,如果把编写程序类比成一个搭积木的过程,每个模块就是一个一个的小积木。在拼装积木之前,我们要保证每个积木的质量都经过了严格的测试。
单元测试的本质其实就是通过写测试代码来测试被测试的代码。在实际执行过程中,我们可能需要很多辅助类功能,形如用例管理、用例组织、测试断言、显示测试进度、友好的测试信息输出以及其他高级的用法。
以测试一个加法函数为例:
#define CheckEqual(X, Y) \
if (X == Y) std::cout<<"check " << #X << " == " << #Y << " success."<
运行上面的代码,我们可得到下面的控制台输出。
check AddFunc(9, 10) == 19 success.
check AddFunc(9, 10) == 20 failed.
这样我们就实现了一个最简单的测试断言。
所以测试框架只是在一定程度上封装了一系列有助于我们编写测试代码的功能和方法,提高我们编写测试代码的效率。理解了这一点,将有助于我们更好的理解和使用测试框架。
为什么要写单元测试,我们可以轻而易举的在搜索引擎或者相关专业书籍上面看到如下答案:提高代码质量和可靠性、支持代码重构、减少调试时间、提高开发效率等等,这些说法虽然正确,但未免显得过于专业和官方,缺乏一定的说服力。下面我想谈几点自己的理解。
玩过英雄联盟的人会感受到,在游戏过程中什么时间段最爽?就是当你干掉对方英雄时,听到随之而来的“First Blood”,“Double Kill”、“Killing Spree”等等。这种对你的行为立马给予的评价就是即时反馈。如果用一句话来概括,即时反馈就是一种“用来表明我们的行为正在导向目标和成功的信号”,这种信号既可以来自于内在的自我,也可以来自外部评价。同样,单元测试也可以给编码者提供及时反馈。
相信大家在编程入门时,最爽的时刻肯定是Main函数运行成功并在屏显上打印“Hello World!”的时刻。那么在实际开发过程中,基于良好的构建工程和测试用例,我们是可以做到一边编码一边运行单元测试。相信在编码过程中不断地看到编译“0 warnning, 0 error”,单元测试运行“0 error”,内心也是暗爽的。
这里主要是想讨论解决问题的成本。当问题发生在不同的时机,其解决问题的成本是不同的,甚至是呈指数级增大。考虑一个“处理委托时未把关键字段正确处理,导致影响正常交易”的问题:
事不过三,三则重构
很多人在没有正式接触重构思想之前,可能会存在以下的认知误区“运行好好的代码,我干嘛要重构它?”
重构并不是说我要将以前的实现全部推翻重写,也不是一件应该特地拨出一段时间来做的事情。重构不是目的,而是一种帮助你把事情做好的手段。
什么是重构?摘自《重构 - 改善既有代码的设计》里面的描述:不改变软件可观察行为的前提下,改善其内部结构以提高理解性和降低修改成本。
随着业务的变更迭代,代码也随着变的越来越糟。这就是常说的代码坏味道:
我们应该至少在离开营地时,让营地比我们到来时更干净
这句话的意思是每次在变更一段代码时,至少不让代码变得比我刚开始改动时更糟糕(参考上面提到的坏味道)。如果每次经过一段代码,都让其变得更干净,积少成多,垃圾都会被清理掉。
所以我们每个人都应该在提交代码时停下来想一下,我的这次提交是让代码更健康了,还是更糟糕了,还是没有变化?一个糟糕的例子就是我们增加了一段重复代码,而一个健康的例子就是在增加功能的同时,顺便重构了之前的代码,让其可读性更高,复用性更强。
如果我们每个人都能做到营地法则,那至少能够让向坏方向旋转的齿轮停下来。
那为什么说单元测试能够支持代码重构呢?在重构过程中肯定会有这样的担忧“重构的风险太大,担心引入新的bug”,如果没有自测试的代码,那么存在这种担忧就是完全合理的。但如果有一套完备的测试套件,就可以保证代码随时处于一个健康的状态。
小范围的重构完,跑一遍单元测试,如果单元测试都通过,那至少说明我们的重构没有破坏原有代码逻辑的正确性。不过这里的前提是得保证单元测试存在一个合理的覆盖率和覆盖范围;如果整个单元测试就零星几条用例,那运行是否通过对于检查重构是否成功不具备任何参考意义。
在讲软件架构设计原则时,经常提到一句话叫“高内聚,低耦合”。一个好的设计一定是低耦合的,这样可以延迟决策,降低决策成本,也可以并行开发,提高代码开发效率。然而除了设计(代码)评审以外,并没有一个很好的方法来促成良好的设计。
单元测试可以很好的起到这样的作用,一旦耦合度过高,那么在执行case,输入数据时就会变得异常困难。这样的问题会反过来促进我们在编码时尽可能的不依赖,或者合理依赖。
考虑以下场景:
A模块存在参数P,而参数P存在于B组件下发的C文件当中
如果我们采取以下的设计:
那么我们在测试A模块时,就需要造一份符合测试要求的文件C,供A模块加载完之后进行测试。如果B组件是跨组维护,且文件C时二进制数据文件且没有文档描述。那么造文件C就会变成一件耗费人力和时间成本的事情。
改进设计:
那么我们在测试A模块时,造数据就变得异常简单。只需要在测试代码中调用一行Set方法即可。参数加载模块应该有其单独的测试用例,在这个用例中才应该关心C文件的内容和结构。那有的读者心里可能会问:“那不是同样还需要关注C文件吗?”,虽然是这样,但这种做法既简洁了架构设计,做到单一职责。今后B组件导致C文件的变更不会影响到A模块。同时也可以提高开发效率,在人力允许的情况下,A模块和参数加载模块可以并行开发,完全不耦合。
单元测试不仅起到了测试的作用,在一定程度上还是一种很好的“文档”。通过阅读单元测试代码,我们可以不需要深入的阅读代码实现,就能知道这段代码的作用和用法;同样,给新人安排完善单元测试用例的工作任务,也是一种很好的学习入门手段。
正如引言里面所描述的那样,在一个项目中重要的部分只占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进行检查。
以市场字段转换为例:
/**
* @brief MarketID转换
* @param market_id[in] 源市场字段
* @return market_id 目的市场字段
*/
uint16_t GetMarketID(uint8_t market_id)
throw(std::invalid_argument);
形如以上的声明,我们应该有如下的测试代码
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也会出现在组件测试或者集成测试中。
比如说A模块依赖B模块,但是B模块还没开发完成或者是由其他人开发,那么你就可以创造一个mock的B对象,并按照预期返回对应结果供A模块调用。
根据我的实际使用来看,Mock有两种实现模式:
即什么时候开始写单元测试?
二八原则能够很好的阐述“单元测试究竟要写多细?”这个问题,即重要复杂的模块多写,不重要简单的模块少写;但是它只能作为指导思想来描述大体的测试方向。
在具体编写用例时,可以考虑将待测试的模块想象成一个个的积木,整个软件是由不同的积木拼装起来。单元测试要做的事情就是保证每个积木的质量完备性,即需要针对待测试接口的每个case进行测试,尤其是各种异常边界条件的检查。
假想一下,如果一个软件有三块很重要的积木,每个积木有10种输入输出,如果把它组合起来,那就有101010=1000种输出输出,如果功能测试要将所有场景全部覆盖,那将是指数级别的用例数量。如果在单元测试里面写用例,那只需要10 + 10 + 10 = 30条用例就完成了所有输入输出的检查。
所以这也是为什么在组件测试中不推荐有过多边界条件检查用例的原因,常规来说只要覆盖最常见的生产场景即可,即正常的输出输出;因为组件测试更多的是集成测试,检查将组件内部各个模块串起来能否正常工作。
尽量不要为了运行单元测试,而去修改代码实际的运行路径;如果代码中出现了形如
#ifdef UNITTEST
// dosth.
#else
// dosth.
这样的代码,那应该思考是否违背了某些设计原则,导致代码不可测试,考虑重构这段代码使其可被测试。
如果是通过宏定义控制其虚函数属性,为了达到被mock的目的,是可以接受的;因为修改虚函数属性本质上没有改变代码的实际运行路径。
但如果通过宏定义修改其公有/私有属性,其实也是不被提倡的; 因为在类设计时,如果一个资源被声明为私有,那么就说明它是被隐藏起来的实现细节,不希望被外界所关注,同时也代表着它是易变的。可能会随着需求的变更调整其实现细节;所以在测试时,应该要从测试单元的可观察行为来出发。
单元测试执行要快,所有的用例运行时间应该要控制在秒级别,而不是几分钟;只有快,才能保证测试效率,才能保证“及时反馈”。如果用例执行时间太久,对程序员的耐心是一个不小的消耗。
在一定程度上,程序员就是单元测试的用户,如果单次时间太久,久而久之势必会影响到程序员对单元测试的热情。
借用《架构整洁之道》里面的一句话:软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求。
同样,我们写单元测试也是一样的目的:通过发现错误,改进设计的做法来达到提高程序质量和可维护性的目的。
《架构整洁之道》-罗伯特·C·马丁 Robert C. Martin
《重构:改善既有代码的设计》第二版 -马丁·福勒 Martin Fowler
《测试驱动开发》-肯特·贝克 Kent Beck