单元测试-基础篇

前言

提到单元测试,大多数程序员或多或少都有接触,尤其在敏捷团队更是家常便饭,因为敏捷中的测试已经不是在项目最后阶段才做的事,它在任何时候都有介入,甚至在编码之前(TDD)。但是就像其他非功能性需求(Non-Functional Requirements)一样,并没有受到很多程序员的重视。

1. 什么是单元测试

很多程序员都认为自己写过单元测试,但他们很可能着另一件事-集成测试。那什么是单元测试,它和集成测试有什么区别呢?下面我们来看一条教科书似的定义(摘抄自Wikipedia)

在计算机编程中,单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

通常单元测试是针对函数逻辑覆盖、异常处理、返回预期以及调用关系进行的测试。它和集成测试的最主要区别就是粒度的大小,集成测试是对某个具体事务或功能的测试,它会涉及多个对象或模块的相互协作。而单元测试往往是独立的单元(函数或类)而且粒度更小。如果你运行自己的单元测试需要耗费大量的时间,那么你很可能已经在做集成测试了。有时我们被测的代码依赖于网络、数据库等IO操作,如果我们的测试还要链接这些对象,那我们就已经不是在做单元测试了。因为没有做好代码和模块之间的解耦,很多单元测试最后都做成集成测试。怎样做才是好的单元测试呢?在这之前让我们先聊聊为什么要做单元测试。我们要有目的做一件事,必须知道他能给我们带来什么,而不仅仅是为了做而做。

2. 为什么要单元测试

单元测试作为测试的一种,当然主要的任务是为发现项目产品中的缺陷,但是它更主要的是从代码的细节出发,为开发人员的日常开发带来高效和便利。

  • 快速、可重复性的回归测试

通过持续集成,一旦提交代码,单元测试就会立即执行,这样便能及时的发现问题,我们知道任何问题发现的越早就越容易解决,花费的成本就越低。而且一旦单元测试写好后便可以重复性的进行回归测试,任何影响原有功能的代码都能立即被发现。

  • 面向接口设计

之前提到单元测试的被测目标是独立的最小单位。那么在设计代码可测性的时候,我们就必须考虑代码的独立性以及被测代码和其他代码的依赖性,而这些标准也正是我们设计优秀程序时必须考虑的重要因素。所以提前设计单元测试会促使我们在设计代码时更多的应用设计模式,依赖注入等方法来降低被测代码的耦合度。

  • 敢于做重构

很多时候有责任感的程序员看到意大利面式的代码总想来一次大展身手的重构,但是理想很丰满,现实却很骨感,因为你根本无从下手,稍微改一点代码,就可能破坏已有功能,最后你之前的自信满满都会随着bug的不断复现而消失殆尽。这样的重构成本太高,但是你不做,最后就像破窗理论(Broken windows theory)越是没人打理,最后越糟糕。换言之,如果你有高覆盖率的单元测试,首先就不会出现像意大利面一样纠缠不清的代码,其次即使有,那么我们有单元测试的保障,我们可以放心大胆的重构,即便这些代码不是我们写的,或是这些代码的需求已经无处考证。

  • 很好的帮助文档

如果你刚接触一个新项目或第三方库,你该如何开始研究它?看代码。那是当然的,问题是该从什么地方开始呢?如果项目有单元测试,我建议你先看单元测试。单元测试其实是一个很好的帮助文档,它告诉我们被测代码是如何使用的,其内部实现的关注点是什么以及如何快速的调试代码。这些是否比起一大堆的需求、设计文档摆在你面前要好的多。

以上优点都在提高代码长期的可维护性,而可维护性是非功能需求最为重要特性之一,它与程序员日常工作息息相关。我们知道程序员大部分时间是在维护产品代码,在已有代码中开发新功能,如果代码的可维护性提高了,那么维护的成本就会降低,当然最后的收益者也是程序员自己,那我们何乐而不为!

3. 好的单元测试应该什么样

既然单元测试如此重要,那么什么样的单元测试是好的单元测呢?

  • 可维护

单元测试的代码应该和产品代码一样重视,一旦单元测试变的难维护了,那么以后就再也没有人会愿意在里面添加新的单元测试,慢慢的代码覆盖率也就降低了,最后单元测试的作用越来越小,最终被抛弃。如何编写可维护维护的单元测试,我将在之后的实战篇进行介绍。

  • 快速运性

好的单元测试必须是可以快速运行,只有这样我们才能把它合并到持续集成中去,快速的得到反馈。如果跑完一组单元测试要花上几个小时,那么最后厌倦了等待程序员就会忽略单元测试的结果,甚至懒得再跑单元测试。

  • 可明显提示运行结果

单元测试的结果必须可视化,只有明显的提示运行结果才可能引起程序员的注意,否则让它默默无闻的在后台运行而不关心运行结果那么单元测试就形同虚设。所有的辛苦都为了最终我们可以看到我们提交的代码是否在已有系统中运行良好。

  • 高代码覆盖率

理论上除了UI界面,其他代码都应该有单元测试的覆盖,它包括业务逻辑,算法,基础框架的实现等。如果单元测试的覆盖率太低,就体现不了单元测试的优势,最后可能被放弃。所以有目的设置一个覆盖率标准便于单元测试的执行和实施。

  • 只专注于被测代码

每个测试用例应该只测一件事,这不仅便于维护而且清晰明了,如果在测试用例中还有一大堆测试逻辑,那么测试代码将更加难维护。好的测试用例一般分三步(AAA): 组装数据(Arrange)— 调用方法(Act) — 比较期望(Assert)。另外利用依赖注入和Mock框架来替代被测代码的依赖模块,可以使测试环境更简单,测试更专注于被测代码本身。

下面我们就聊聊Test Double。

4. 测试替身(Test Double)

被测代码独立没有依赖固然是好,但是这都是理想状态,任何系统都是由若干相互依赖的模块共同作用才能正常运行,我们只能降低耦合不可能消灭所有的依赖。那么单元测试如何测试有依赖的代码呢(比如被测代码需要与网络或数据库交互)?难道要用集成测试代替单元测试,当然不是,我们可以用一些轻量级的小对象来替代复杂对象来简化测试环境,并保证更纯粹的单元测试。在《xUnit测试模式》中就提到了一些常用的替代对象,这些对象我们统称为Test Double:

Dummy Object:泛指在测试中必须传入的对象,而传入的这些对象实际上并不会产出任何作用,仅仅是为了能够调用被测对象而必须传入的一个东西。

Fake Object:用来替代一个实际的对象,并且拥有几乎和实际对象一样的功能,保证被测系统能够正常工作。(比如我们可以用内存数据替代像SQLServer等磁盘数据库)。

Stub Object:用来接受被测系统内部的间接输入(indirect inputs),并返回特定的值给被测系统。

Mock Object:用来预先模拟一些预期,并在被测代码调用后来验证是否完成了预期。

这些概念比较抽象,尤其Stub ObjectMock Object很容易混淆,简单的说Stub主要是对状态的模拟验证,而Mock主要是对行为的模拟验证。如你感兴趣可以参看Martin Flowers的Mocks Aren’t Stubs我这里我也不想班门弄斧,之后我希望能写一篇Mock实践,那样结合代码可能更加容易理解。

5. 测试框架平台工具

最后我想简单介绍一些单元测试的工具。很多开源或者商用的框架都可以帮助我们快速的部署持续集成环境。比如下面提到的Jenkins,我们可以通过配置相应的编译器,测试框架以及覆盖率统计工具,让每次我们提交代码后,可视化的显示代码大编译情况,单元测试的通过率以及测试覆盖率的统计结果。以下我列举了一些工具和框架(附有相关学习和介绍的链接),虽然每种框架都各有特色,但是它们的核心本质都是一样的。你可以根据自己的项目,经验进行选择。如何搭建我相信链接中都有文档说明,剩下就靠你自己了。

  1. 持续集成工具

    Jenkins

  2. 单元测试框架

    C++: GTest(google)MSTest

    .Net:NUnit, MSTest

    Java:JUnit

  3. Mock框架

    C++:GMock(google)

    .Net:Moq, MSFakeFramework

    Java:JMock, EasyMock, Mockito

  4. 单元测试覆盖率工具

    C++:OpenCppCoverage

    .Net:OpenCover

    Java:Jacoco

后记

最后我还是想再强调一次非功能性需求(Non-Functional Requirement),它不像功能性需求那样有直观的标准去考核它,所以非功能性需求往往被忽视(即使在程序员内心中承认其重要性)。其实我个人觉得其他人忽视非功能性需求我可以理解,唯独程序员不应该,因为它给程序员带来不仅仅是个人技术的提高,而且也让我们在长期的工作中受益颇多。功能性需求由PM(Product Manager)决定,而程序员应该是非功能性需求的主人。

(转载本站文章请注明作者和出处,请勿用于任何商业用途)

上一篇:关于Code Review的那些事
下一篇:单元测试-实践篇(MsTest)