-
将客户端逻辑封装进Javascript对象,并用Jsunit做单元测试 - [Test]
2008-11-04
最近的一个Web项目大部分的逻辑都是客户端页面逻辑,主要是通过Javascript实现的,如何进行高覆盖率的单元测试是我们面临的一个问题。
分 析一下,有两种逻辑需要测试,一个是与服务器交互的页面操作,比如提交表单后跳转到另一个页面,这很适合写Selenium测试。这里关注的是另外一种, 即客户端页面逻辑,它与服务器没有交互,比如页面Dom元素的联动变化和数据校验逻辑等,这部分当然也可以写Selenium来测,而且由于不与服务器打 交道,组织得当的话速度也很快。但此时Selenium测试与代码实现的反馈距离太远,不方便组织细粒度的测试,更重要的是写这个测试对 Javascript代码的设计没什么帮助,因为再烂的Javascript实现可以方便地写出Selenium测试。
对于客户端页面逻辑,我们尝试使用Jsunit进行测试。对于没有Dom操作的纯Javascript函数,测试很容易。对于有一定复杂度并有Dom操作的逻辑,我们将他们封装成Javascript对象并用Jsunit测试它。
一个具体的例子

上 图是页面的一部分,业务上叫Reference,是要输入一条或多条由Type和Number组成的Pair。选择Type,输入合法的Number后点 击Add,加入到列表中,也可从列表中删除一行。如果在Type列表中选择了Other,右框变为可编辑,可输入自定义的Type。主要逻辑大致如此。
有这样一个需求,选择Type但没有输入Number时,点击Add什么都不做,我们期望能这样写Jsunit测试:
function testShouldDoNothingIfTryAddingReferenceWithEmptyNumber(){
reference.type.value = "SOME_TYPE";
reference.number.value = "";
reference.add();
assertEquals(0, reference.resultList.length);
}为了能写出这样简洁的测试,我们要:
将对Web页面区域进行操作的Javascript逻辑封装为对象
对 于一般的页面,将Javascript写成一堆全局的变量和函数是最简单的。当逻辑变得复杂,代码量增加后,这样组织代码就显得力不从心了,id在函数间 传来传去,函数参数增多,命名困难,全局变量组织凌乱。此时,利用对象可以帮助组织好代码。页面的这一区域代表一个完整概念,我们将对这一区域封装成一个 对象,代码的布局大致是这样的:
var reference = {
init: function(){
this.type = document.getElementById("reference_type");
this.number = document.getElementById("reference_number");
this.resultList = document.getElementById("reference_list");
// init other dom fields...
// register event handler for dom elements...
},
add: function(){
if(!this.validate()){
return;
}
// add type and number pair to list
if(this.selectExistingType()){
var optionValue = this.fields.type.value + " - " + this.filelds.number.value;
var opt = new Option(optionValue, code);
this.resultList.options.add(opt);
}
else{
// add user specified type and number to result list ...
}
}
// other methods...
};
$(document).ready(function(){
reference.init.call(reference);
});在Document Ready的时候调用init函数将各个Field准备好,并在此时设置事件处理函数,在add函数中可以直接使用Field或调用其它函数。
对象提供必要的属性和函数
根据这一区域反映的概念,它应该提供关于reference的必要操作,实际上这些操作都是后面的Jsunit测试驱动出来的,因为这些代码在运行时接受用户UI操作,可能并不会直接使用到这些函数。所以说部分函数是因测试而开放的。
对 于上面简单的例子,就是暴露出各个Field和Add等函数,具体实现时,还会产生很多私有性质的函数。Javascript有几种方式实现私有的 Field和函数,或者不在语法上做限制,只是加一个“_”在函数名前作为私有函数的命名习惯,我个人认为这些做法都会让代码看起来不自然,难懂,在 Javascript里做访问限制的意义不大,清晰易懂是最重要的。
其实,随着需求进一步丰富(见后面一例),客户端其它部分也需要使用这个对象了,它们面对的将是抽象出来的概念,而不需要知道页面元素的Id及其它内部实现细节。
在Jsunit测试页面中创建Javascript对象依赖的各个Dom元素
reference对象是不能独立运行的,因为它依赖一组Dom元素,代码要通过Id操作这些Dom元素,在Jsunit的测试页面中,我们将这些元素一一创建出来,Jsunit测试页面的布局大致是这样的:
<html>
<head>
<script src="../../app/jsUnitCore.js"></script>
<script src="../../app/jquery-1.2.6.js"></script>
// include js to test and other dependent js files
<script language="JavaScript" type="text/javascript">
var bodyBackup;
function setUpPage(){
// backup dom tree
bodyBackup = $('body').html();
// jsunit need this line to complete page setting up.
setUpPageStatus = 'complete';
}
function setUp(){
// reset whole dom tree
$('body').html(bodyBackup);
}
function testShouldDoNothingIfTryAddingReferenceWithEmptyNumber(){
reference.type.value = "SOME_TYPE";
reference.number.value = "";
reference.add();
assertEquals(0, reference.resultList.length);
}
// other tests...
</script>
</head>
// below is a minimized dom tree reference object depends on
<body>
<select id="reference_type">
<option value=''></option>
<option value='SOME_TYPE'></option>
<option value='OTH'></option>
</select>
<input id='other_reference'/>
<input id='reference_number'/>
<input id="add_reference" type="button"/>
<input id="delete_reference" type="button"/>
<select id='reference_list'/>
</body>
</html>这里准备的Dom Tree要比Jsp中真实页面简单许多,对象仅仅依赖这些元素,而非更详尽的页面布局。可以体会得到,这样做的测试覆盖率是很高的,代码在对真实的Dom元素做操作。
我 们注意到这里出现了“重复”,如果真实页面增加了新的元素或更改了Id,测试数据也要相应改变。这个重复是可以被消除掉的,比如将对象中代表各个页面 Dom元素的各个Field组织到一个单独的对象中,每个Field多加一个Property记录Dom元素Tag名,这样就可以在每个测试开始前动态地 创建出这一组Dom元素,而不是像上面那样硬编码到测试页面中,原来的对象也并不需要为此做多少变化。这样做的好处是消除了重复,不必硬编码,但不如将 Dom元素直接写到测试页面中直观。因此当那个“重复”没有给我们带来那么多痛苦之前,建议还是直接把Dom元素写到测试页面中好了。
其实上面的很多东西都是通过先写测试驱动出来的,比如对象该提供什么样的函数等等。
扩展到更复杂的情况
下面是一个更复杂的例子:
这是一个Container(集装箱)列表,每个Container包含一个或多个Cargo(货物),Container和Cargo都有各自的属性,它们之间有很多关联和逻辑,而且都是动态创建出来的。
总的思路没有变化,只是对Javascript代码设计的要求高一些,保证测试代码能够很自然地创建对象,进行测试,就像使用Java对象一样。
比如Cargo有一个功能,可以将自己拷贝到其它Container中,这是通过选择Cargo右侧的Copy Cargo To列表实现的,该列表中有目前创建的所有Container。我们期望这样写测试:
function testShouldCreateNewCargoFromSourceCargoIfNoEmptyOneInTargetContainer(){
// create a container with one cargo of specified state
var sourceCargo = containerList.addContainer().getCargos()[0];
sourceCargo.setState({quantity:100});
// create another container with a non-empty cargo
var targetContainer = containerList.addContainer();
targetContainer.getCargos()[0].setState({quantity:50});
// 2 - the third item in 'copy cargo to' list, means the second container.
sourceCargo.copyToContainer(2);
var cargos = targetContainer.getCargos();
assertEquals(2, cargos.length);
assertEquals(100, cargos[1].getState().quantity);
}测试中的代码和运行时是一样的,都会动态创建完整的Dom树,注册事件等等。针对这一比较复杂的情况,我们做:
进一步封装和抽象
实现类的代码结构比上面例子复杂些。这里主要涉及3个类,分别是ContainerList,Container和Cargo,以Cargo为例,代码结构大致是这样的:
var Cargo = function(container){
this.container = container;
this.fields = {};
this.createUI();
}
Cargo.prototype = {
// will be called after ui is added to dom tree
onDomReady: function{
this.fields.quantity = this.ui.find("input[@name=quantity]")[0];
this.fields.description = this.ui.find("textarea[@name=description]")[0];
// setup other fields...
// setup validation and other initializing logic...
},
copyToContainer: function(selectedIndex){
var container = this.getContainerFromCopyToList(selectedIndex);
container.addCargo(this.getState());
},
createUI: function() {
// clone from hidden template in page
this.ui = $("#cargo_prototype").clone();
// add ui to parent container...
},
// other methods...
}
// inherit getState, setState and more from StatefulObject
Object.inherit(Cargo.prototype, StatefulObject);这里比较上面一个例子有几点变化:
* Cargo实现为构造函数而不是对象,因为它会动态创建多个。
* 所有Field都是this.fields对象的属性,方便通过For Loop实现对所有Field的统一操作。
* 通过Clone创建Cargo UI,在UI之下使用name而非id来定位dom元素,因为name不要求唯一性。
* 通过getState和setState操作界面。由 于Container和Cargo都有fields,getState(),setState()等功能,我们将它提取到一个父对象中,叫 StatefulObject。Javascript实现父类有N种方式,选择一种IE和Firefox都支持的就行了。这个 StatefulObject的实现大致是这样:
var StatefulObject = {
getState: function(){
var state = {};
for(var p in this.fields){
state[p] = this.fields[p].value;
}
return state;
},
setState: function(state){
for(var p in this.fields){
if(state[p]){
this.fields[p].value = state[p];
}
}
},
// other methods related with state, like isDirty()...
};利用现有的对象实现其它功能
这里的封装和抽象使得简单的测试成为可能,也大大方便了其它相关功能的实现。比如系统有 一个Auto Populate的功能,是指用户在页面中输入订单号后,系统会做Ajax Call,并将返回信息中的Container和Cargo内容自动输入到页面上。这一功能是这样实现的:
function populateContainerAndCargos(containersFromServer) {
for (var i = 0, len = containersFromServer.length; i < len; i++) {
var containerFromServer = containersFromServer[i];
var containerState = {
number :containerFromServer.containerNumber,
bookingNumber :containerFromServer.bookingNumber,
// map other fields from server to container state object...
};
var container = containerList.addContainer(containerState);
var cargosFromServer = containerFromServer.associatedCargos;
for(var i = 0, len = cargosFromServer.length; i < len; i++){
var cargoFromServer = cargosFromServer[i];
var cargoState = {
quantity : cargoFromServer.quantity;
// map other fields from server to cargo state object
};
container.addCargo(cargoState);
}
}
}其中充分利用了Container和Cargo对象中的现有功能(见代码中加粗的两句),如果不利用Javascript对象封装,要实现这一功能那肯定是个苦差事。
总结
像上面这样,Javascript代码变得OO了,被更好的组织起来,进而使得单元测试变得更容易写,而且测试覆盖率很高,和Selenium测试或手动的点击页面已经很接近了。最后就是更容易写出黑盒的测试,不需要Mock,测试更容易直接体现需求意图。
随机文章:
单元测试一问一答 2008-10-11Unit Test Make Smelly Code More Smelly 2008-05-20A Quick Check of Javascript Code Quality 2009-03-30Make visible both progress and smell 2009-01-05
收藏到:Del.icio.us
利用客户端Javascript代码简化Selenium测试
Blog:路宁的博客2008-12-23 17:10:06

评论