Skip to content

Instantly share code, notes, and snippets.

@abruzzi
Last active August 29, 2015 14:01
Show Gist options
  • Save abruzzi/feb8923fa367bf99791a to your computer and use it in GitHub Desktop.
Save abruzzi/feb8923fa367bf99791a to your computer and use it in GitHub Desktop.
单元测试测什么?

单元测试测什么?

单元测试中,最常见的错误是追溯被测试组件的所有依赖,并尝试将这些依赖实例化。一不小心就将单元测试做成了集成测试。

有依赖关系的测试

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>&gt;' +
        '<strong>lastCrumbData</strong>');
});

这个验证将测试与实现强烈的绑定到了一起,比如这个”&gt”,测试的初衷是元素中应该有一个表示朝向的大于号。但是其实最关心的是朝向本身,而不是大于号。也就是说,在编写这个测试的时候,应该考虑将实现修改为一个更加表意的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的一些高级特性

Jasmine除了基本的断言和用例的支持外,作为一个完整的测试运行器,它提供更为强大的spy功能。

使用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');
});

更多的参考资料

jasmine how do i jasmine jasmine test part 2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment