单元测试中,最常见的错误是追溯被测试组件的所有依赖,并尝试将这些依赖实例化。一不小心就将单元测试做成了集成测试。
EmailClient是一个邮件客户端,这个客户端有一个接口:
var client = EmailClient(emailSender);
client.send(emailAddr, message);
而在实现的内部,该客户端使用了某个网络库来完成实际的发送操作:
var EmailClient = function(underlying) {
var server = 'smtp://10.100.100.10:25';
return {
send: function(address, message) {
underlying.connectTo(server);
return underlying.send(address, message);
}
}
};
在这个例子中,EmailClient依赖于地称的emailSender对象,当调用EmailClient的send时,实际上会先调用emailSender的connectTo函数连接到服务器,然后再通过emailSender的send方法来发送消息。
那么对于EamilClient来说,应该测什么呢?如果配置一台SMTP的服务器在10.100.100.10,然后调用EmailClient的send方法,然后在服务器上查看该邮件是否真的被发送,这种测试为集成测试。如果10.100.100.10服务器没有启动,那么这个测试会失败;如果该用户不存在,测试也会失败;如果网络连接有异常,测试还是会失败。换言之,这样的测试的代价太大,而且对环境的依赖太多。
单元级别的测试,就是假设依赖的所有环境都已经就绪而进行的测试。放到这个例子里来说,就是假设后台的服务器是有的,网络也没有故障,邮件地址也合法,或者说,假设emailSender经过了所有完整的测试。在这种情况下,我们如何测试EmailClient呢?
GEO的代码库中的有一个geoStorageService,这是一个最容易测试的场景之一,又比如RequestParamBuilder.js/licenseHelper.js等,这些模块都只关注某一方面的逻辑计算,测试时并不需要考虑依赖关系。
对于这类模块,应该用各种异常情况,边界情况去验证代码的正确性。比如geoStorageService的测试:
define(['services/services', 'services/geoStorageService'], function() {
'use strict';
describe('local storage', function() {
var storage = null;
beforeEach(function() {
module('services');
inject(function(StorageService) {
storage = StorageService;
});
storage.clear();
});
it('should be empty at the very beginning', function() {
});
it('should be able to save data', function() {
});
it('should distinct duplicated data', function() {
});
it('should be able to get saved data', function() {
});
it('should be able to save complex data', function() {
});
it('should be able to clear all the data stored', function() {
});
});
});
首先,需要一个好的名字。
describe('network monitoring controller', function() {
it('should have toggle of setting form', function() {
});
});
最外层的describe中,描述测试的模块是哪个?每个it中,描述当前case是在测试那种场景。比如测试某个controller上带有某个选项,而且这个选项的初始值是true/false等,这些都属于单元测试的范畴。
测试的另一个目的是对异常情况的检测,比如如果输入是空值,程序的相应什么?如果预期是一个数组,但是传入的是一个对象,那么结果又是什么?又或者预期的是数字,但是输入是字符串,日期等。
比如,我们来看一个例子:
var element, scope;
beforeEach(function () {
module('directives');
inject(function ($compile, $rootScope) {
scope = $rootScope;
scope.defaultColor = 'rgb(255, 255, 255)';
var html = '<geo-colorpicker ng-model="defaultColor"></geo-colorpicker>';
element = angular.element(html);
$compile(element)($rootScope);
});
});
it('should display color data', function () {
scope.$apply();
expect(element.find('div').attr('class')).toEqual('colorSelect ng-pristine ng-valid');
expect(element.find('div').css('background-color')).toEqual('rgb(255, 255, 255)');
});
这段代码测试了geo-colorpicker
这个指令,当传入指令的ng-model是rgb(255,255,255)
的时候,指令生成的html片段中包含了一个div,而且这个div的background-color值恰好是rbg(255,255,255)
。
这个用例当然是有意义的,但是不够全面,比如如果不传值(ng-model=''),这个指令的输出是什么?在此处并没有定义。或者如果传了一个#004c97
,这个指令是否能正常工作?也没有定义。
对于验证DOM结构的地方,比如下面这个例子:
it('should display crumb data', function () {
scope.$apply();
expect(element.html()).toEqual('<a href="#">otherCrumbData</a>>' +
'<strong>lastCrumbData</strong>');
});
这个验证将测试与实现强烈的绑定到了一起,比如这个”>”,测试的初衷是元素中应该有一个表示朝向的大于号。但是其实最关心的是朝向本身,而不是大于号。也就是说,在编写这个测试的时候,应该考虑将实现修改为一个更加表意的HTML片段,比如:
<a href="#"><i class="forward"><span>lastCrumbData</span></i></a>
这样,后续的样式的改动并不会导致这个测试的失败(很可能这个大于号在某天被修改成了一个图标或者fonticon),但是又恰到好处的验证了逻辑的正确性。
再来看另外一个例子:
it('should display no panes', function () {
scope.$apply();
expect(element.find('.tab-cont').html()).toEqual('<geo-pane ' +
'itabitem="Healthy Score" panestyle="fa fa-star" ' +
'class="ng-scope ng-isolate-scope"><div class="tab-pane" ' +
'ng-show="selected" ng-transclude=""></div></geo-pane>');
});
这段代码检查了tab-cont指令的输出。但是此处的检查太过于严格,并且又是与依赖强烈相关。事实上,此处对诸如’ng-scope’,ng-isolate-scope
的检查完全没有必要,这些angularjs本身内部的实现与需要测试的逻辑没有关系。
expect(element.find('.tab-cont').find('div.tab-pane').length).toEqual(1);
测试本身更关注的是,tab-cont下有一个div,且这个div的class是tab-pane即可。
Jasmine除了基本的断言和用例的支持外,作为一个完整的测试运行器,它提供更为强大的spy功能。
比如我们在服务中,有对外部应用的依赖:
var realBusiness = function(callback) {
$.ajax({
url: 'business.do',
success: function(data) {
callback(data);
}
})
};
realBusiness(function(list) {
$('#map').html(list);
});
RealBusiness这个函数依赖于后台的business.do,它会发送请求到business.do,得到数据之后回到传入的callback来更新id为map的DOM元素的内容。
但是实际中,我们肯定不期望真正发送这个请求,因为后台未必实时都是就绪的,并且后台的依赖也可能不能实时满足(数据库中没有数据)。这时候可以使用spy来解决这个问题:
describe('realBuesiness', function() {
it('should send request to server', function() {
spyOn($, 'ajax');
realBusiness(undefined);
expect($.ajax).toHaveBeenCalled();
});
});
即,此处spy在$.ajax上。那么在后边的代码中,无论何处调用了$.ajax,jasmine都不会真实的调用ajax,而是调用这个spy。并且会记录所有的调用记录。
因此这里可以预期$.ajax被调用了(toHaveBeenCalled)。当然,仅仅被调用了还不足以说明程序的正确性,因此jasmine提供了更多的方法:
it('should call callback when success', function() {
var callback = function() {};
spyOn($, 'ajax').andCallFake(function(e) {
e.success({});
});
realBusiness(callback);
expect($.ajax.mostRecentCall.args[0].url).toEqual('business.do');
});