• 将客户端逻辑封装进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,测试更容易直接体现需求意图。


    收藏到:Del.icio.us

    评论

  • 虽然我不是很懂技术,但是能看出你的用心,加油哦!