4.5 测试替身(Test Double)
开发人员编写的测试用例会设置有用户场景,执行应用的某个特定功能单元,然后验证功能单元的返回结果。如果被测的功能单元不依赖其他单元,那么测试会相对简单。但是在实际环境下,一般被测单元总会依赖其他的功能单元:
• 被测单元需要输入数据,它的运行会受到输入数据的影响。这些输入数据可能是测试用例以回调函数等形式提供给被测的功能单元,也有可能是被测单元调用它所依赖的第三方功能单元的API,然后以依赖单元的返回值作为输入数据,例如通过访问数据库获得数据。
• 被测单元需要输出结果。输出的结果可能是以函数返回值的形式返回给宿主程序,也可能功能单元本身不直接返回数据,而是调用一系列第三方功能单元的API,这些调用依赖单元的行为也是一种输出结果。
当被测单元依赖第三方单元时,单元测试就变得复杂起来。虽然可以将功能单元和它的依赖单元一起进行测试,但是这些依赖单元可能在测试环境中难以建立,或者难以返回测试所需要的数据。此外,单元测试需要尽可能快速地运行,若被测单元调用依赖的第三方子系统(比如数据库)则可能会花费比较长的时间。
另一种方案就是将被测的功能单元和依赖单元隔离,创建一些比较简单但是行为和实际依赖单元类似的假单元来代替真实的依赖单元,以降低测试的复杂性和提高测试的可行性。
Gerard Meszaros借鉴电影替身(Double)的概念,将这些假单元称为测试替身(Test Double)。
4.5.1 测试替身的类型
测试替身主要提供两项功能:为被测单元提供输入数据和记录被测单元的输出结果。
当依赖单元提供输入数据时,单元测试需要控制依赖单元的行为,让依赖单元提供不同类型的数据来测试功能单元的各种行为。如果用测试替身替换依赖单元,则测试替身一般会模拟提供以下种类的测试数据:
• 方法/函数的返回值
• 可更新参数的值
• 可以抛出的异常
执行单元测试后需要验证测试结果,有时候被测单元本身不返回测试结果,而是调用依赖单元(例如日志记录模块)间接输出结果,这时是无法通过函数返回值来验证被测单元是否执行成功的,而需要检查日志记录模块是否记录了相应的操作,来间接验证测试结果。如果用测试替身替换依赖单元,那么测试替身需要有记录调用行为的功能。
根据使用形式的不同,测试替身有以下类型。
1.Dummy Objects
被测单元的函数/方法可能会接受一些参数,然后将传入的参数对象存在实例变量里供以后使用。有时候这些参数对象不会在当前的单元测试过程中被使用,所以它们不会影响被测函数/方法的行为。
因为这些参数对象不会被使用,所以在单元测试中创建这些实际的参数对象是没有必要的,而只需要简单并且没有依赖的“假”对象。这样的“假”对象就是Dummy Object。
最简单的Dummy Object是null(nil, nothing),但有时候被测函数/方法要求输入的参数是not-null,这种情况下只能被迫创建一个真实对象。对于动态类型语言(例如JavaScript),可以使用String或Object;对于静态类型语言(例如C#、Java),必须确保Dummy Object和对应的参数对象类型相匹配。
2.Test Stubs
被测单元在执行时通常需要调用一些第三方的依赖单元。调用依赖单元的结果会影响到被测单元的行为。换一个角度讲,调用依赖单元的结果成为了被测单元的输入数据。在单元测试中,利用“假”的对象代替实际的依赖单元,这样“假”对象返回的各种预设结果会影响被测单元的行为,确保被测单元内的代码都被测试到。这种 “假”对象称为Test Stub,如图4-10所示。
图4-10 Test Stub类型
Test Stub一般会实现依赖单元的接口(interface)预先设置返回结果。在准备测试场景时,Test Stub用来取代实际的依赖单元,这样测试执行时被测单元调用Test Stub, Test Stub返回预先设置的结果给被测单元,两者的交互完全在被测单元内部,对测试用例透明。当被测单元执行完成后,测试用例会验证被测单元的执行结果。
3.Test Spies
有时被测单元本身不返回测试结果,但它会调用依赖单元间接输出执行结果,例如日志记录模块。为了验证被测单元是否按预期执行,可利用“假”对象来代替实际的依赖单元。这个“假”对象会记录并保存所有对它的调用信息。这种假对象称为Test Spy,如图4-11所示。
图4-11 Test Spy类型
当被测单元执行完成后,测试用例会检查Test Spy中保存的信息来验证被测单元是否按预期执行。
4.Mock Objects
Mock Object和Test Spy类似,也是代替实际的依赖单元,记录并保存被测单元对它的所有调用信息。和Test Spy不同的是,Mock Object除了记录信息以外,它会将被测单元对它的调用和测试用例预先设置的期望行为进行比较,一旦发现被测单元的行为偏离预期,就立即使测试失败。换句话说,Mock Object在Test Spy的基础上加入了验证的功能,如图4-12所示。
图4-12 Mock Object类型
由于Mock Object封装了测试验证的逻辑,所以Mock Object可以被不同测试用例所重用。在使用Mock Object的情况下,测试用例本身理论上不需要验证代码,它完全信任Mock Object的验证结果。Mock Object一旦发现被测单位行为异常,可以立即使测试失败,这样可以快速有效地定位错误发生的位置。与之相反,Test Spy没有验证功能,只能依赖测试用例在被测单元执行完成后再进行判断,所以Test Spy必须记录更详细的诊断信息才能定位错误位置。
5.Fake Objects
有时在测试环境中建立依赖单元比较困难,或者调用依赖单元要花很长时间,就要用到Fake Objects。Fake Object拥有几乎和实际依赖单元一样的功能,用它来替换实际依赖单元,被测单元仍然能够正常工作,如图4-13所示。例如使用一个内存数据库取代实际的数据库就是一个常见的Fake Object使用场景。
图4-13 Fake Object类型
Fake Object和Test Stub、Test Spy以及Mock Object的区别在于它只是为了减少对外部环境的依赖,是一种替代实现,并不提供控制输入输出和验证等功能。
4.5.2 使用Jasmine Spies
了解了什么是测试替身,那么如何创建测试替身呢?虽然用户可以根据测试需求进行手工创建,但是效率很低,因此通常会使用一些流行的JavaScript测试替身库(例如Sinon. JS)来实现。Jasmine可以使用Sinon.JS,但Jasmine其实有内建的测试替身库。
在Jasmine里,各种测试替身统称为Spy。开发人员使用Jasmine Spies来替代真实的函数/方法或者对象,并且跟踪它们的调用记录和传入的所有参数。Jasmine Spies只存在于describe块或者it块中,在每个测试用例使用结束后被销毁。
1.使用spy跟踪函数的调用
假设类CarAssemble有3个成员函数addWheel、addEngine和assemble,其中,assemble函数会调用addWheel和addEngine(assemble依赖addWheel和addEngine),但是assemble函数本身不返回结果,有如下代码:
var CarAssemble = function () { this.wheel = 0; this.engine = null; }; CarAssemble.prototype.addWheel = function(){ this.wheel += 1; }; CarAssemble.prototype.addEngine = function(engineName){ this.engine = engineName; }; CarAssemble.prototype.assemble = function(){ this.addWheel(); this.addWheel(); this.addWheel(); this.addWheel(); this.addEngine('V8'); };
因为assemble函数没有返回结果,为了验证它是否成功执行,需要使用spy来跟踪addWheel和addEngine的调用。以下是测试用例代码:
describe('Spies Test', function () { it('for spyOn against CarAssemble function', function () { var fake = new CarAssemble(); // Replace addWheel function with a spy spyOn(fake, 'addWheel'); // Replace addEngine function with another spy spyOn(fake, 'addEngine'); fake.assemble(); expect(fake.addWheel).toHaveBeenCalled(); expect(fake.addWheel).toHaveBeenCalledTimes(4); expect(fake.addEngine).toHaveBeenCalledWith('V8'); expect(fake.addWheel.calls.any()).toEqual(true); expect(fake.addWheel.calls.count()).toEqual(4); }); });
以上测试用例中,为了测试assemble函数,首先创建了一个CarAssemble对象,然后调用spyOn函数创建spy代替addWheel和addEngine这两个实际函数。此时fake.addWheel和fake.addEngine已经不是实际函数而是spy了。
spyOn是Jasmine的一个全局函数,用来创建一个spy并且代替一个已存在的函数。其用法如下:
// assumes obj.method is an existing function spyOn(obj, 'method');
接下来执行被测代码fake.assemble并验证spy是否如预期被调用。Jasmine提供了spy相关的匹配器,如表4-4所示。
表4-4 spy相关的匹配器
如果需要进行否定判断,例如期望spy被调用时不会传入某些参数,可以在expect和匹配器之间加not。例如:
expect(fake.addEngine).not.toHaveBeenCalledWith('V6');
除了以上匹配器,访问spy的calls属性还可以获得对spy的调用信息,如表4-5所示。
表4-5 spy的calls属性
2.使用spy调用实际函数
spyOn函数创建的spy只能记录被调用的信息,而不会修改任何数据,也不影响其他函数或对象的状态。有时除了需要跟踪函数的调用,用户还希望在测试时能执行实际的函数,并更新被测单元的数据。此时只要在spyOn创建的spy后面链式调用and.callThrough,就可以让spy在被调用时,除了记录调用信息,还继续调用实际函数。
it('for spyOn when configured to call through', function () {
var fake = new CarAssemble();
spyOn(fake, 'addEngine').and.callThrough();
fake.assemble();
expect(fake.addEngine).toHaveBeenCalled();
expect(fake.engine).toBe('V8');
});
3.使用spy控制函数的返回结果
单元测试中,有时希望利用“假”对象返回各种可能的结果来影响被测单元的行为,这时可以在spyOn函数创建的spy后面链式调用and.returnValue,指定返回结果。这样每次调用spy都可以得到这个指定的返回值。
it('for spyOn when configured to fake a return value', function() {
var engineSupplier = {
getEngine: function() {
return 'V8';
}
};
spyOn(engineSupplier, 'getEngine').and.returnValue('V6');
var val = engineSupplier.getEngine();
expect(val).toBe('V6');
expect(val).not.toBe('V8');
});
如果要设定一个结果列表,可以在spy后面链式调用and.returnValues,每次调用spy,就会依次从这个结果列表返回一个值。如果所有的值都被返回了,那么Jamsine会返回undefined。例如:
it('for spyOn when configured to fake a series of return values', function() {
var engineSupplier = {
getEngine: function() {
return 'V8';
}
};
spyOn(engineSupplier, 'getEngine').and.returnValues('V6', 'V4');
expect(engineSupplier.getEngine()).toBe('V6');
expect(engineSupplier.getEngine()).toBe('V4');
expect(engineSupplier.getEngine()).toBeUndefined();
});
如果在spy后面链式调用and.throwError,那么任何对该spy的调用都会抛出指定的错误。示例代码如下:
it('for spyOn when configured to throw an error', function () {
var engineSupplier = {
getEngine: function () {
return 'V8';
}
};
spyOn(engineSupplier, 'getEngine').and.throwError('broken engine');
expect(function () {
engineSupplier.getEngine();
}).toThrowError('broken engine');
});
链式调用and.callThrough、and.returnValue等其实是为创建的spy增加了新功能,用户可以在任何时候调用.and.stub将spy恢复到原始状态(去除这些新功能,只能记录调用信息)。示例代码如下:
it('can fake a value and then stub in the same spec', function () {
var engineSupplier = {
getEngine: function () {
return 'V8';
}
};
spyOn(engineSupplier, 'getEngine').and.returnValue('V6');
expect(engineSupplier.getEngine()).toBe('V6');
engineSupplier.getEngine.and.stub();
expect(engineSupplier.getEngine()).toBeUndefined();
});
4.使用spy将实际函数替换成新函数
有时候需要在测试时使用一个全新的函数实现替换实际的函数,此时可以在Jasmine中spyOn后面的链式中调用and.callFake,以指定一个新函数。例如:
it('for spyOn when configured with an alternate implementation', function() {
var engineSupplier = {
getEngine: function() {
return 'V8';
}
};
var fakeEngine = function() {
return 'Faked Engine';
};
spyOn(engineSupplier, 'getEngine').and.callFake(fakeEngine);
expect(engineSupplier.getEngine()).toBe('Faked Engine');
});
5.创建新的spy函数
前面介绍了在单元测试中创建spy替换实际已存在的函数(例如addWheel)。有时在单元测试中技术人员只需要一个“空”函数,这时可使用jasmine.createSpy来创建一个全新的spy函数(不用替换实际函数)。和其他spy一样,这个新的spy函数可以跟踪函数的调用和传入的参数,但是本身没有实现代码。示例代码如下:
it('for a spy, when created manually', function() {
var engine = jasmine.createSpy('Named Engine');
engine('Faked Engine');
expect(engine).toHaveBeenCalled();
expect(engine).toHaveBeenCalledWith('Faked Engine');
});
和spyOn一样,在createSpy后面也可以链式调用and.ReturnValue、and.callFake等函数。例如:
jasmine.createSpy('V6 Engine').and.returnValue('V6'); jasmine.createSpy('V8 Engine').and.callFake(function() { return 'V8'; });
6.创建spy对象
使用jasmine.createSpyObj函数可以创建一个spy对象。示例代码如下:
it('for multiple spies, when created manually', function() {
var engine = jasmine.createSpyObj('Named Engine', ['start', 'stop']);
engine.start();
engine.stop();
expect(engine.start).toBeDefined();
expect(engine.stop).toBeDefined();
expect(engine.start).toHaveBeenCalled();
expect(engine.start).toHaveBeenCalled();
});
jasmine.createSpyObj函数的第1个参数是对象名称,第2个参数是字符串数组,数组内每个字符串代表spy对象的一个属性,都是spy函数。在上面的示例中,start和stop都是engine对象的成员函数,作为spy函数使用。