关于前端开发单元测试
单元测试Unit Test
很早就知道单元测试这样一个概念,但直到几个月前,我真正开始接触和使用它。究竟什么是单元测试?我想也许很多使用了很久的人也不一定能描述的十分清楚,所以写了这篇文章来尝试描述它的特征和原则,以帮助更多人。
什么是单元测试?
先来看看单元测试的定义,在维基百科英文版中可以找到Kolawa Adam在 Automated Defect Prevention: Best Practices in Software Management 一书中对单元测试的定义:
In computer programming, unit testing is a method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures are tested to determine if they are fit for use.
重点在于最后,单元测试的目的显而易见,用来确定是否适合使用。而测试的方法则包括控制数据,使用和操作过程。那么以我的理解,每个单元测试就是一段用于测试一个模块或接口是否能达到预期结果的代码。开发人员需要使用代码来定义一个可用的衡量标准,并且可以快速检验。
很快我发现有一个误区,许多人认为单元测试必须是一个runner集中运行所有单元的测试,并一目了然。不,这仅仅是一种自动化单元测试的最佳实践,在一些小型项目中单元测试可能仅仅是一组去除其他特性的接口调用。甚至在一些图形处理或布局的项目中单元测试可以结合自身特性变的十分有趣,比如 Masonry ,一个网格布局库,在它的单元测试中不是一个红或绿的条目,而是一行一行的小格布局用以说明布局被完成的事实,这样比代码检查布局是否正确再以颜色显示结果来得更直观高效,也避免了测试程序本身的bug导致的失误。
打个比方,单元测试就像一把尺子,当测量的对象是一个曲面时,也许可以花费大力气去将它抽象成平面,但我更提倡量身定做一把弯曲的尺子去适应这个曲面。无论怎样,单元测试是为了生产代码而写,它应当足够的自由奔放,去适应各种各样的生产代码。
为什么要单元测试?
也许定义中已经很清楚的指明了其意义,确认 某段代码或模块或接口是否适合使用,但我想会有更多的人认为,直接在测试环境中使用软件可以更加确保软件是否可用。不,在实际使用过程中会伴随着一大批的附带操作大量增加测试时间,并且无法保证其测试覆盖率。所以我认为单元测试的目的并不仅仅是确认是否可用,而是更高效更稳定的确认其是否可用。
随着项目规模的增加,函数、方法、变量都在递增,尤其是进度的不足,来自产品经理的压力,还有QA所带来的各种Bug报告会让原本整洁的代码变得一片混乱。我甚至见过同一个接口以不同的名称出现在8个不同的控制器中。这时也许我们首先想到的是重构,可是等等,在重构结束时我们如何确定项目仅仅是被重构了,而不是被改写了?此时单元测试将是一根救命稻草,它是一个衡量标准,告诉开发人员这么做是否将改变结果。
不仅仅是这样。许多人认为单元测试,甚至整个测试都是在编码结束后的一道工序,而修复bug也不过是在做垃圾掩埋一类的工作。但测试应该伴随整个编码或软件周期进行,还有将在后面提到的TDD这样有趣的东西,单元测试将超前于编码。我的意思是,单元测试应该是一个框架、标准,经常被形容被脚手架,像建筑一样,脚手架的高度至少应该和大楼高度不相上下,甚至一开始就搭好脚手架。
如何做单元测试?
弄清了单元测试的目的和意义,但如何开始?很简单,首先它是一个检验,所以应该只有pass或fail两种情况。而检验的对象应该是某个接口或模块,所以应该调用它获得一个结果。检验这个 结果就是单元测试的基本动作,就拿一个除法函数来做例子:
function division (a, b) { return a / b;}var result = division(4, 2);if (result === 2) { alert('pass');} else { alert('fail');}
显然,将会提示pass通过。但是问题来了,这个测试的用例太单一和普通了,如果使用0做除数呢?是NaN?还是Infinity?或者在实际使用时,产品需要一个0来代替这样一个不符合数学概念的结果去适应必须为数字类型的某种计算,于是division出现了一个bug。另外当覆盖率增加,也意味着用例的增加,我们需要把if条件语句提出来做成一个函数多次调用。还有alert方法,如果用例太多,我相信你会点确认点到手软,也许可以直接显示在页面上。
所以我添加了一个关于除数为0的用例,并重构了代码:
function division (a, b) { if (b === 0) { return 0; } else { return a / b; }}function matcher (name, result, expect) { if (result === expect) { _print(name + '- pass'); } else { _print(name + '- fail'); } function _print (str) { var _bar = document.createElement('p'); _bar.innerText = str; document.body.appendChild(_bar); }}matcher('normal', division(4, 2), 2);matcher('zero', division(5, 0), 0);
现在可以使用matcher方法添加许多测试用例,并且还能为该用例命名,在页面中直接显示每个用例是否通过。这样一个基本的单元测试就完成了,当然它的覆盖率还远远不够,这里仅作为一个例子。另外为了提高效率还应该使用颜色来标记是否通过,可以一目了然。
测试驱动开发
TDD是Test Driven Development 的缩写,也就是测试驱动开发。
通常传统软件工程将测试描述为软件生命周期的一个环节,并且是在编码之后。但敏捷开发大师Kent Beck在2003年出版了 Test Driven Development By Example 一书,从而确立了测试驱动开发这个领域。
TDD需要遵循如下规则:
- 写一个单元测试去描述程序的一个方面。
- 运行它应该会失败,因为程序还缺少这个特性。
- 为这个程序添加一些尽可能简单的代码保证测试通过。
- 重构这部分代码,直到代码没有重复、代码责任清晰并且结构简单。
- 持续重复这样做,积累代码。
另外,衡量是否使用了TDD的一个重要标准是测试对代码的覆盖率,覆盖率在80%以下说明一个团队没有充分掌握TDD,当然高覆盖率也不能说一定使用了TDD,这仅仅是一个参考指标。
在我看来,TDD是一种开发技术,而非测试技术,所以它对于代码构建的意义远大于代码测试。也许最终的代码和先开发再测试写的测试代码基本一致,但它们仍然是有很大不同的。TDD具有很强的目的性,在直接结果的指导下开发生产代码,然后不断围绕这个目标去改进代码,其优势是高效和去冗余的。所以其特点应该是由需求得出测试,由测试代码得出生产代码。打个比方就像是自行车的两个轮子,虽然都是在向同一个方向转动,但是后轮是施力的,带动车子向前,而前轮是受力的,被向前的车子带动而转。
行为驱动开发
所谓的BDD行为驱动开发,即Behaviour Driven Development,是一种新的敏捷开发方法。它更趋向于需求,需要共同利益者的参与,强调用户故事(User Story)和行为。2009年,在伦敦发表的“敏捷规格,BDD和极限测试交流”[3]中, Dan North 对BDD给出了如下定义:
BDD是第二代的、由外及内的、基于拉(pull)的、多方利益相关者的(stakeholder)、多种可扩展的、高自动化的敏捷方法。它描述了一个交互循环,可以具有带有良好定义的输出(即工作中交付的结果):已测试过的软件。
另外最主观的区别就是用词,‘example’取代了‘test’,‘describe’取代了‘class’,‘behaviour’取代了‘method’等等。这正是其特征之一,自然语言的加入,使得非程序人员也能参与到测试用例的编写中来,也大大降低了客户、用户、项目管理者与开发者之间来回翻译的成本。
简单来说,我认为BDD更加注重业务需求而不是技术,虽然看起来BDD确实是比ATDD做的更好,但这是一种误导,这仅仅是就某种环境下而言的。而且以国内的现状来看TDD要比BDD更适合,因为它不需要所有人员的理解和加入。
单元测试框架
无论如何,单元测试永远是少不了的。其实在单元测试中测试代码和生产代码应该是等量的,正如Robert C. Martin在其 Clean Code: A Handbook of Agile Software Craftsmanship 一书中所写:
测试必须随生产代码的演进而修改,测试越脏就越难修改
于是新的测试很难被加入其中,测试代码的维护变得异常困难,最终在各种压力之中只有扔掉测试代码组。但是没有了测试代码,就失去了确保对代码的改动能如愿以偿的能力,各 种问题随之而来。因此,单元测试也需要一种行之有效的实践来确保其质量和可维护性。
所以正如生产代码一样,测试代码也有框架,下面介绍几种主流的Javascript的单元测试框架。
Jasmine
有一类框架叫做xUnit,来源于著名的JAVA测试框架JUnit,xUnit则代表了一种模式,并且使用这样的命名。在Javascript中也有这样的一个老牌框架JsUnit,他的作者是Edward Hieatt来自 Pivotal Labs ,但在几年前JsUnit就已经停止维护了,他们带来了新的BDD框架Jasmine。
Jasmine不依赖于任何框架,所以适用于所有的Javascript代码。使用一个全局函数 describe 来描述每个测试,并且可以嵌套。describe函数有2个参数,一个是字符串用于描述,一个是函数用于测试。在该函数中可以使用全局函数 it 来定义Specs,也就是单元测试的主要内容, 使用 expect 函数来测试:
describe('A suite', function () { it('is a spec', function () { var a = true; expect(a).toBe(true); });});
另外如果想去掉某个describe,无须注释掉整段代码,只需要在describe前面加上x即可忽略该describe。
Matcher
toBe方法是一个基本的 matcher 用来定义判断规则,可以看得出来Jasmine的方法是非常语义化的,“expect ‘a’ to be true”,如果想判断否定条件,则只需要在toBe前调用 not 方法:
expect(a).not().toBe(false);
除了toBe这样基本的还有许多其他的 Matcher ,比如 toEqual 。很多初学Jasmine会弄不清和toBe的区别,一个简单的例子就能明白它们的区别:
expect({}).not().toBe({});expect({}).toEqual({});
一个新建的Object不是(not to be)另一个新建的Object,但是它们是相等(to equal)的。还有 toMatch 可以使用字符串或者正则表达式来验证,以及其他一些特殊验证,比如undefined或者boolean的判断, toThrow 可以检查函数所抛出的异常。另外Jasmine还支持自定义Matcher,以NaN的检查为例,像这样使用beforeEach方法在每个测试执行前添加一个matcher:
beforeEach(function () { this.addMatchers({ toBeNaN: function (expected) { return isNaN(expected); } });});
可以想到,其参数expected是传入的一个期望的字面量,而在expect方法中传入的参数,可以通过 this.acturl 获取,是否调用了 not 方法则可以通过 this.isNot 获取,这是一个boolean值。最后测试输出的失败信息应该使用 this.message 来定义,不过它是一个function,然后在其中返回一个信息。所以继续增进toBeNaN:
beforeEach(function () { this.addMatchers({ toBeNaN: function (expected) { var actual = this.actual; var not = this.isNot ? ' not' : ''; this.message = function () { return 'Expected ' + actual + not + ' to be NaN ' + expected; }; return isNaN(expected); } });});
这样一个完整的matcher就创建成了。
另外需要说明的是对应beforeEach是在每个spec之前执行, afterEach 方法则是在每个spec之后执行。这是一种AOP,即面向方面的编程(Aspect Oriented Programming)。比如有时候为了测试一个对象,可能需要多次创建和销毁它,所以为了避免冗余代码,使用它们是最佳选择。
还可以使用 jasmine.any 方法来代表一类数据传入matcher中,比如
expect(123).toEqual(jasmine.any(Number));expect(function () {}).toEqual(jasmine.any(Function));
Spy方法
一个Spy能监测任何function的调用和获取其参数。这里有2个特殊的Matcher, toHaveBeenCalled 可以检查function是否被调用过,还有 toHaveBeenCalledWith 可以传入参数检查是否和这些参数一起被调用过,像这样使用 spyOn 来注册一个对象中的方法:
var foo, a = null;beforeEach(function () { var foo = { set: function (str) { a = str; } } spyOn(foo, 'set'); foo.set(123);});it('tracks calls', function () { expect(foo.set).toHaveBeenCalled(); expect(foo.set).toHaveBeenCalled(123); expect(foo.set.calls[0].args[0]).toEqual(123); expect(foo.set.mostRecentCall.args[0]).toEqual(123); expect(a).toBeNull();});
在测试时该function将带有一个被调用的数组 calls ,而 args 数组就是调用时传入的参数,另外特殊属性 mostRencentCall 则代表最后一次调用,和calls[calls.length]一致。需要特别注意的是,这些调用将不会对变量产生作用,所以 a 仍为null。
如果需要调用产生实际的作用,可以在spyOn方法后调用 andCallThrough 方法。还可以通过调用 andReturn 方法设定一个返回值给function。 andCallFake 则可以传入一个function作为参数去代替原本的function。
spyOn(foo, 'set').andCallThrough();
甚至在没有function的时候可以使用Jasmine的 createSpy 和 createSpyObj 创建一个spy:
foo = jasmine.createSpy('foo');obj = jasmine.createSpyObj('obj', [set, do]);foo(123);obj.set(123);obj.do();
其效果相当于spyOn使用在了已存在的function上。
上面的方 法都在程序顺序执行的前提下执行,但 setTimeout 以及 setInterval 两个方法会使代码分离在时间轴上。所以Jasmine提供了 Clock 方法来模拟时间,以获取setTimeout的不同状态。
beforeEach(function () { jasmine.Clock.useMock();});it('set time', function () { var str = 0; setTimeout(function () { str++; }, 100); expect(str).toEqual(0); jasmine.Click.tick(101); expect(str).toEqual(1); jasmine.Click.tick(200); expect(str).toEqual(3);});
使用Clock的方法 useMock 来开始时间控制,然后在it中使用 tick 方法来推进时间。
Javascript最大的特色之一就是异步,之前介绍的方法如果存在异步调用,大部分测试时可能会不通过。因此,需要等异步回调之后再进行测试。
Jasmine提供了 runs 和 waitsFor 两个方法来完成这个异步的等待。需要将waitsFor方法夹在多个runs方法中,runs方法中的语句会按顺序直接执行,然后进入waitsFor方法,如果waitsFor返回false,则继续执行waitsFor,直到返回true才执行后面的runs方法。
var cb = false;var ajax = { success: function () { cb = true; }};spyOn(ajax, 'success');it('async callback', function () { runs(function () { _toAjax(ajax); }); waitsFor(function () { return ajax.success.callCount > 0; }); runs(function () { expect(cb).toBeTruthy(); });});
如此,只要在waitsFor中判断回调函数是否被调用了即可完成异步测试。上面代码中我使用一个方法名直接代替了ajax请求方法来缩减不必要的代码。在第一个runs方法中发出了一个ajax请求,然后在waitsFor中等待其被调用,当第二个runs执行时说明回调函数已经被调用了,进行测试。