第二章 单元测试的基本概念和核心技法
2.1 良好的单元测试——定义
我们已经了解了程序员需要单元测试,下面我们来给单元测试作一个完整的定义:
● 定义: 单元测试是一段自动执行的代码,它调用被测类或被测方法,然后验证关于被测类或被测方法逻辑行为的假设确实成立。单元测试几乎总是用单元测试框架(unit testing framework)来写就的,单元测试是易于写就、执行快速、完全自动化、值得依赖、易于阅读并易于维护的。
这个定义有点长,但是它却包含了大量重要信息:
● 单元测试的测试重点是被测类或被测方法的逻辑行为。所谓“逻辑行为”,指的是含有诸如判断、循环、选择、计算或其他决策过程的代码。
● 单元测试框架是辅助程序员写就单元测试的利器。
● 单元测试的代码本身,同被测代码一样,也应该是值得依赖、易于阅读并易于维护的。
有了单元测试的定义,我们来看看有关单元测试的几个基本概念,这些基本概念会在后面的章节中反复出现。
● 被测类(Class Under Test)和被测方法(Method Under Test):顾名思义,就是测试代码所操练的类或方法。
● 测试类(Test Fixture)和测试方法(Test Method):负责操练被测类和被测方法。Test Method一般都是Test Fixture的成员函数。
● 测试运行器(Test Runner):负责自动执行测试类中的测试方法。Test Runner可以是命令行界面的,也可以是GUI的。
2.2 进行单元测试的核心技术和核心手法
2.2.1 核心技术
前面已经讲到,单元测试所针对的目标是“单个”类或“单个”方法(这里的“方法”指C++中的自由函数)。这表明我们在单元测试中要做的主要任务是创建出被测类的对象,并调用该对象的被测方法。但是我们都知道,一个类几乎不可能完全不依赖于其他类。这种类之间的依赖会导致我们无法顺利地将一个被测类纳入单元测试覆盖这下,因为单元测试需要的是对被测类这一个类的测试,而不是同时测试被测类和它的合作者类。
因此,我们在把ClassUnderTest纳入单元测试时,也就必须先把ClassUnderTest与CollaboratorClass之间的依赖“打破”,这个过程称为“解依赖”(dependency-breaking)。解依赖就是进行单元测试的核心技术。解依赖的目标是希望能把不可控的CollaboratorClass替换成由我们控制的伪合作者类(FakeCollaboratorClass),并使被测类能方便地选择是依赖于真实的合作者类还是伪合作者类。对于产品代码,被测类依赖的是真实的合作者类,而在单元测试中,被测类依赖的是由我们控制的伪合作者。
在单元测试代码中使用可控的FakeCollaboratorClass,这给我们带来了两个便利:
● 我们可以通过FakeCollaboratorClass向ClassUnderTest返回任何我们指定的结果。
● 我们可以通过FakeCollaboratorClass来感知ClassUnderTest所做的动作。
这实际上就是FakeCollaboratorClass的两种表现形式:
● Stub:用于向ClassUnderTest返回我们指定的结果。
● Mock:用于感知ClassUnderTest的行为。
我们明白了“解依赖”是单元测试的核心技术。那么具体怎样实现解依赖呢?下面我们就来介绍4种相关的核心手法,其中前2种与CollaboratorClass有关,后2种与ClassUnderTest有关,这4种手法对于解决绝大多数的解依赖问题都适用。
2.2.2 “接口提取”和“Virtual and Override”
“接口提取”手法是对CollaboratorClass提取出一个抽象接口CollaboratorService,然后让CollaboratorClass和FakeCollaboratorClass都去实现这个接口,而ClassUnderTest则由直接依赖CollaboratorClass转向依赖于抽象接口CollaboratorService, 如下图所示。
实际上,“接口提取”手法是一种非常好的手法,它使得我们的代码遵循“依赖抽象原则”,遵循这个原则的软件具有较好的灵活性,这是具有可测试性的软件也具有较好的设计的一个佐证。
而“Virtual and Override”手法则是使CollaboratorClass中的被依赖方法成为virtual,然后让FakeCollaboratorClass去公有继承CollaboratorClass,并且override那些虚函数,从而替换掉 CollaboratorClass中的行为。这种手法如下图所示。
总体上讲,我们推荐优先使用“接口提取”手法,因为这将使代码遵循“依赖抽象原则”,从而使软件更好地应对今后的变化。但是,“Virtual and Override”方法也是有其一席之地的,我们后面会看到例子。
2.2.3 “参数注入”和“Extract and Override”
在ClassUnderTest这边,对CollaboratorClass的依赖的产生方式也可以划分成两类:
依赖是通过方法参数传入的,这种形式的依赖被称为“参数注入”式依赖(parameter injection dependency)。参数注入式依赖是一种耦合度较低的依赖产生形式,因此对ClassUnderTest的影响不大,一般最多只需要把方法的签名由直接依赖CollaboratorClass改成依赖接口CollaboratorService。
依赖是在被测方法的方法体内部产生的,这种依赖被称为“隐藏式”依赖(hidden dependency)。隐藏式依赖有多种表现形式:
● 直接创建CollaboratorClass对象作为局部变量或成员变量。
● 通过一个工厂方法来产生CollaboratorClass对象。
● 通过一个工厂类来产生CollaboratorClass对象。
隐藏式依赖是一种耦合程度较高的依赖,因此是我们着重需要“打破”的依赖。一种方法是把隐藏式依赖转变成参数注入式依赖,我们将在后面的小节中看到这种方法的应用。而另一种方法,则是使用“Extract and Override”手法,即:我们给ClassUnderTest引入一个virtual的工厂成员函数,来返回一个CollaboratorClass对象的引用,然后对ClassUnderTest派生出一个子类,在该子类中override这个工厂成员函数,让它返回一个FakeCollaboratorClass对象的引用,如下图所示。
这里的TestingClassUnderTest被称为“测试用子类”(Testing Subclass)。这时,在Test Fixture中被实例化的其实就是测试用子类,而不是被测类本身。
以上我们研究了进行单元测试所需要的核心技术,以及4种最常用的核心手法,这些手法足以应付绝大多数的情况,但是,仍然有一些特殊情况需要我们特别注意,我们从下一节开始,以Q&A的形式,讨论这些特殊情况。
2.3 应付构造函数中的隐藏式依赖
当ClassUnderTest中出现隐藏式依赖时,最常用的有两种手法来打破这种依赖。我们分别来看一下。
2.3.1 转变成参数注入式依赖
对于像C#和Java这样的语言,由于它们支持在一个构造函数中去调用另一个构造函数,因此可以很方便地增加一个构造函数,把隐藏式依赖转变成参数注入式依赖。下面的UML图阐释了这种手法。
而对于C++,由于它没有提供在构造函数中调用另一个构造函数的功能,因此通常的作法就是把公共的的初始化代码放入一个init()私有方法中去,让不同的构造函数去调用init()方法。
2.3.2 使用“调包方法”
还可以考虑给ClassUndertTest引入一个“调包方法”,使得测试类和测试方法可以很方便地将合作者类“调包”成伪合作者类。这里的“调包方法”本质上就是一个setter方法,但是为了表明这个特殊的setter方法只应该被测试类和测试方法调用,我们一般给调包方法命名为SupersedeCollaborator()。下图就是一个演示。
这里必须要提醒的是,在C++中使用这个手法时必须注意在“调包方法”中不能引起资源泄漏,也就是说,必须先释放掉原有的合作者对象,再以伪合作者替代之。
2.4 怎样测试ClassUnderTest中的private方法?
如果要测试一个类的private方法,答案的总体思路是:适当打破访问权限。也就是说,我们可以把private方法声明成protected方法,然后对ClassUnderTest进行派生,得到一个“测试用子类”,在该“测试用子类”中声明一个public方法,该方法是ClassUnderTest中的protected方法的代理。这种手法可以用下图来表示。
2.4 应付“全局依赖”
所谓“全局依赖”,是指被测类或被测方法所依赖的合作者是一个“全局单件”,包括C#/Java/C++中的“单件类”(Singleton)和C++中的全局变量和自由方法(实际上, Singleton可视为全局变量在面向对象语言中的变种)。我们下面来看看怎么应对这些情况。
2.4.1 使用“封装全局引用”手法来解除对全局变量和自由方法的依赖
要打破对全局变量和自由方法的依赖,其实基本思想就是:把它们纳入到一个类中,让它们成为一个类的成员变量和成员方法。一旦把全局变量和自由函数封装进了某个类,那么我们就可以继续利用2.2.2节提到的两种手法来引入伪合作者了。
2.4.2 使用“静态调包方法”来解除对单件类的依赖
单件类往往具有3个特点:
(1)单件类的构造函数通常被设为private。
(2)单件类具有一个静态成员变量,该成员变量持有该类的唯一一个实例。
(3)单件类具有一个静态方法,用来提供对单件实例的访问,通常该方法名叫getInstance()。
由于单件类被设计成强制性地在整个程序中只有一份,因此要伪造它比较困难。我们推荐使用“静态调包方法”手法。这个手法的本质是适当打破单件类的单件性约束,把单件类的构造函数改为protected,使我们能够从单件类派生出一个Fake子类,然后为单件类引入一个静态调包函数SupersedeInstance(),以允许单件类中的静态成员变量被“调包”成Fake子类对象。下图表明了这一手法。
同样必须强调的是,在C++中的使用这个手法的时候,必须保证没有资源泄漏。
2.5 如果CollaboratorClass本身就处于继承体系中,怎么办?
先设想一下CollaboratorClass本身存在基类的情况,如下图所示。
如果使用“Virtual and Override”手法来伪造合作者类,那么不存在任何问题,我们可以用下面的图来表示。
另一方面,如果想使用“接口提取”手法的话,那么一种比较好的策略是使用“外覆类”(Wrapper Class),如下图所示。
2.6 当CollaboratorClass是标准类库的一员时,怎么办?
有些时候,我们的被测类所依赖的是特定操作系统(Windows, Unix, 或Mac)、特定标准规范(.NET,或J2EE)、特定函数库或类库(如POSIX API和Win32 API)及特定用户界面(CUI或者GUI)所提供的功能时,这实际上是引入了对特定平台的依赖性,而这往往都是在提示我们:应该加入一个更高层次的抽象(也即一个间接层,indirection),从而将这种对特定平台的依赖隐藏到这个抽象之后。换句话说,我们应该引入一个接口,来使我们的代码具有平台无关性,如下图所示。
2.7 怎样测试抽象接口?
假设我们的系统中定义了一个抽象接口ServiceInterface,系统中有两个类(分别是ServiceImpl1和ServiceImpl2)实现了这个接口。现在,我们希望为ServiceInteface抽象接口编写一个通用的测试类,这个测试类不仅能测试当前已经实现该接口的类,而且可以不加修改地应用于将来实现ServiceInteface接口的类。应该怎么办呢?下图展示了一种可能的方案。
上图中,ServiceInterfaceTestFixture测试类中的测试方法都是基于ServiceInterface来进行测试的,不依赖于其具体实现类。这样就保证了仅测试抽象接口所定义的行为。当将来系统引入ServiceInteface的新的实现类时,只需要从ServiceInterfaceTestFixture类再派生出一个新子类,并实现createServiceInstance()方法来创建相应的对象即可。