Skip to main content

关于前端开发单元测试

单元测试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执行时说明回调函数已经被调用了,进行测试。

Qunit

它是由jQuery团队开发的一款测试套件,最初依赖于jQuery库,在2009年时脱离jQuery的依赖,变成了一个真正的测试框架,适用于所有Javascript代码。

Qunit采用断言(Assert)来进行测试,相比于Jasmine的matcher更加多的类型,Qunit更集中在测试的度上。 deepEqual 用于比较一些纵向数据,比如Object或者Function等。而最常用的 ok 则直接判断是否为true。异步方面Qunit也很有趣,通过 stop 来停止测试等待异步返回,然后使用 start 继续测试,这要比Jasmine的过程化的等待更自由一些,不过有时也许会更难写一些。Qunit还拥有3组AOP的方法( done 和 'begin' )来对应于整个测试,测试和模块。

对于Function的跟踪测试,Qunit似乎完全没有考虑。不过可以使用另外一个测试框架为Qunit带来的插件 sinon-qunit 。这样就可以在test中使用 spy 方法了。

Sinon

Sinon并不是一个典型的单元测试框架,更像一个库,最主要的是对Function的测试,包括 Spy 和 Stub 两个部分,Spy用于侦测Function,而Stub更像是一个Spy的插件或者助手,在Function调用前后做一些特殊的处理,比如修改配置或者回调。它正好极大的弥补了Qunit的不足,所以通常会使用Qunit+Sinon来进行单元测试。

值得一提的是,Sinon的作者 Christian Johansen 就是 Test-Driven JavaScript Development 一书的作者,这本书针对Javascript很详细的描述了单元测试的每个环节。

Mocha

它的作者就是在Github上粉丝6K的超级Jser TJ Holowaychuk ,可以在他的页面上看到过去一年的提交量是5700多,拥有300多个项目,无论是谁都难以想象他是如何进行coding的。

理所当然的,Mocha充满了Geek感,不但可以在bash中进行测试,而且还拥有一整套命令对测试进行操作。甚至使用 diff 可以查看当前测试与上一次成功测试的代码不一致。

不仅仅是这样,Mocha非常得自由。Mocha将更多的方法集中在了describe和it中,比如异步的测试就非常棒,在it的回调函数中会获取一个参数 done ,类型是function,用于异步回调,当执行这个函数时就会继续测试。还可以使用 only 和 skip 去选择测试时需要的部分。Mocha的接口也一样自由,除了 BDD 风格和Jasmine类似的接口,还有 TDD 风格的 (suite test setup teardown suiteSetup suiteTeardown),还有AMD风格的 exports ,Qunit风格等。同时测试报告也可以任意组织,无论是列表、进度条、还是飞机跑道这样奇特的样式都可以在bash中显示。

前端测试工具

Client/Server 测试

相比于服务端开发,前端开发在测试方面始终面临着一个严峻的问题,那就是浏览器兼容性。 Paul Irish 曾发表文章 [Browser Market Pollution: IEx] Is the New IE6 阐述了一个奇怪的设想,未来你可能需要在76个浏览器上开发,因为每次IE的新版本都是一个特别的浏览器,而且还有它对之前所有版本的兼容模式也是一样。虽然没人认为微软会继续如此愚蠢,不过这也说明了一个问题,前端开发中浏览器兼容性是一个永远的问题,而且我认为即使解决了浏览器的兼容性问题,未来在移动开发方面,设备兼容性也是一个问题。

所以在自动化测试方面也是如此,即使所有的单元测试集中在了一个runner中,前端测试仍然要面对至少4个浏览器内核以及3个电脑操作系统加2个或更多移动操作系统,何况还有令移动开发人员头疼的Android的碎片化问题。不过可以安心的是,早已存在这样的工具可以捕获不同设备上的不同浏览器,并使之随时更新测试结果,甚至可以在一个终端上看到所有结果。

JSTD(Javascript Test Driver) 是一个最早的C/S测试工具,来自Google,基于JAVA编写,跨平台,使用命令行控制,还有很好的编辑器支持,最常用于eclipse。不过它无法显示测试对象的设备及浏览器版本,只有浏览器名是不够的。另外JSTD已经慢慢不再活跃,它的早正如它的老。

Google的新贵 Karma 出现了,它使用Nodejs构建,因此跨平台,还支持PhantomJS浏览器,还支持多种框架,包括以上介绍的Jasmine、Qunit和Mocha。一次可以在多个浏览器及设备中进行测试,并控制浏览器行为和测试报告。虽然它不支持Nodejs的测试,不过没什么影响,因为Nodejs并不依赖于浏览器。

还有 TestSwarm ,出自jQuery之父John Resig之手,看来jQuery的强大果然不是偶然的,在测试方面非常到位,各种工具齐全。它最特别的地方在于所有测试环境由服务器提供,包括各种版本的主流浏览器以及iOS5的iphone设备,不过目前加入已经受限。

最受瞩目的当属 Buster ,其作者之一就是 Christian Johansen 。和Karma很像,也使用Nodejs编写跨平台并且支持PhantomJS,一次测试所有客户端。更重要的是支持Nodejs的测试,同样支持各种主流测试框架。不过目前还在Beta测试中,很多bug而且不能很好的兼容Windows系统。它的目标还包括整合 Browser Stack

基于网页的测试

到目前为止我们的测试看起来十分的完美了,但是别忘了,在前端开发中存在交互问题,不能期待QA玩了命的点击某个按钮或者刷新一个页面并输入一句乱码之类的东西来测试代码。即使是开发者本身也会受不了,如果产品本身拥有一堆复杂的表单和逻辑的话。

Selenium 是一个测试工具集,由Thoughtworks开发,分为两部分。Selenium IDE是一个Firefox浏览器的插件,可以录制用户行为,并快速测试。

而Selenium WebDriver是一个多语言的驱动浏览器的工具,支持Python、Java、Ruby、Perl、PHP或.Net。并且可以操作IE、Firefox、Safari和Chrome等主流浏览器。通过 open , type , click , waitForxxx 等指令来模拟用户行为,比如用Java测试:

public void testNew() throws Exception { selenium.open("/"); selenium.type("q", "selenium rc"); selenium.click("btnG"); selenium.waitForPageToLoad("30000"); assertTrue(selenium.isTextPresent("Results * for selenium rc"));}

首先跳转到跟目录,然后选择类型,点击按钮G,并等待页面载入30秒,然后使用断言测试。这样就完成了一次用户基本行为的模拟,不过复杂的模拟以及在一些非链接的操作还需要格外注意,比如Ajax请求或者Pjax的无刷新等等。

另外还有一款可以模拟用户行为的网页测试工具 WATIR ,是Web Application Testing in Ruby的缩写,显然它只支持Ruby语言来操作浏览器模拟用户行为。官方声称它是一个简单而灵活的工具,无论怎样至少就官方网站的设计来看要比Selenium简约多了。同样支持模拟链接点击,按钮点击,还有表单的填写等行为。不过WATIR不支持Ajax的测试。和其他Ruby库一样需要gem来安装它:

gem install watir --no-rdoc --no-ri

然后使用它

require 'rubygems'require 'watir'require 'watir-webdriver'browser = Watir::Browser.newbrowser.goto 'http://www.example.com/form'browser.test_field(:name => 'entry.0.single').set 'Watir'browser.radio(:value => 'Watir').setbrowser.radio(:value => 'Watir').clearbrowser.checkbox(:value => 'Ruby').setbrowser.checkbox(:value => 'Javascript').clearbrowser.button(:name => 'submit').click

这样就使用watir完成了一次表单填写。

持续集成测试

持续集成就是通常所谓的CI(Continuous integration),持续不断的自动化测试新加入代码后的项目。它并不属于单元测试,而是另外的范畴,不过通过使用CI服务可以很容易的在Github上测试项目,而这也就是持续集成的意义。

下面以我的jQ小插件 Dialog 为例介绍一下Travis-CI的使用方法,注册 Travis ,然后链接自己的Github,选择要进行持续集成的项目。此时会显示build failing,那是因为还没有在项目中进行相关配置。

首先需要使用Grunt等工具配置好测试框架的自动化测试,细节可以参考我之前的文章 改进我的Workflow 。然后在 package.json 中添加一下代码来指定执行的脚本:

"scripts": { "test": "grunt jasmine:test"}

接着添加一个文件 .travis.yml 来配置travis:

language: node_jsnode_js: - "0.8"before_script: - npm install -g grunt-cli

language 是集成测试所使用的语言,这里前端开发当然是使用Nodejs,在 node_js 中指定版本即可。当然Travis还支持其他多种语言,以及后端数据库等。

before_script 则是在测试前执行的脚本程序,这里在全局安装Grunt-cli即可,因为默认的Travis会执行 npm install 将package.json中指定的Node包安装到项目。

最后在Github中还需要在项目的Setting中的Service Hooks中配置Travis,输入Token并保存。或者直接在Travis中点击该项目条目中的扳手图标进入Github,会自动配置好。

另外,如果在Github上为README文件添加一行

Build Status

就可以持续直观的显示其测试结果。