all articles

understanding unit test & e2e test in front end development

2016-11-14 @sunderls

js unit test e2e

随着js程序变得越来越复杂,前端测试也变得越来越重要了。2013年正式成为程序员的时候,写的是Angular的应用,用的是karma还是什么的测试框架,嘛,具体我也忘了,反正结论就是unit test还可以写一写,e2e test就有点得费事儿了,页面功能变化很快,写e2e test又很费事,性价比有点低的感觉,所以后来测试也就没怎么上心。

后来参与的项目逐渐增多,逐渐意识到测试还是有很大作用的,比如:

  1. 帮助重构代码。 时间一长,以往的代码就会逐渐觉得不给力,想要重构的时候测试资源却不够的时候就略显为难。如果测试有好好写,js代码也很好地进行了组织,那么重构的时候就有底气的多。
  2. util的公用模块的修改。 这些到处都在用的方法,轻易的修改都有很大风险,所以测试可以进行保障。

总之,测试还是有很多好处的。只要处理好度的问题,不要太过于纠结于细节部分,在最重要的部分用测试代码进行质量控制的话,利还是大于弊的。最近在自己的项目中尝试了一下,以下进行经验分享。

本次的代码已经放在github,欢迎clone体验。https://github.com/sunderls/lstest/blob/master/README.md

实例

以下的说明需要有一个实例,为此我们假设有一个按钮,点击过后,需要跳转到同样的地址,但是url的click参数会递增,代码实现如下:

src/state.js

'use strict';

/**
 * a helper state object
 * to parse/generate url
 */

class State {
    constructor(url) {
        this.base = '';
        this.params = {};
        this.parse(url);
    }

    // parse a url, get params
    parse(url){
        let segs = url.split('?');
        this.base = segs[0];

        if (segs.length < 2){
            return;
        }

        segs[1].split('&').forEach((chunk) => {
            let pair = chunk.split('=');
            if (pair.length){
                this.params[pair[0]] = pair[1];
            }
        });
    }

    // generate a url
    generate(){
        return this.base + '?' + Object.keys(this.params).map((key) => `${key}=${this.params[key]}`).join('&');
    }
}

if (typeof module !== 'undefined'){
    module.exports = State;
}

src/clickButton.js

'use strict';

/**
 * a button changing location when clicked
 */

class ClickButton {
    constructor($dom){
        $dom.addEventListener('click', this.onClick.bind(this))
    }

    onClick(){
        let state = new State(window.location.href);
        if (typeof state.params.click === 'undefined'){
            state.params.click = 0;
        }

        state.params.click = state.params.click * 1 + 1;
        window.history.replaceState({}, 'click', state.generate());
    }
}

if (typeof exports !== 'undefined'){
    exports.ClickButton = ClickButton;
}

如何针对这个案例添加测试内容呢? 首先这个js文件中有两个class,2个主要方法和一个事件处理方法,需要分开进行处理。

提前告诉一下结果可能会有比较好的阅读预期,附图如下:
sampl

1. class State的测试。

1.1 自己用console.assert好了。

chrome浏览器自己有console.assert,node.js所采用的是v8,所以也支持这个方法,我们直接对几个实际的例子进行测试。

let state = new State('http://colla.me?a=3');
console.assert(state.params.a === '3');

state = new State('http://colla.me?a=3&b=4');
console.assert(state.params.a === '3');
console.assert(state.params.b === '4');

state = new State('http://colla.me?a=3&b');
console.assert(state.params.a === '3');
console.assert(state.params.b === undefined);

如果没有问题的话,以上脚本执行过后不会抱错,否则会出现AssertionError。

1.2 使用node.js的assert

也可以用node.js自带的assert library,虽然在这里用上去和上面差不多,但是assert提供了更多的便利方法,具体可以参见官方文档: https://nodejs.org/api/assert.html

const assert = require('assert');

let state = new State('http://colla.me?a=3');
assert.equal(state.params.a, '3');

state = new State('http://colla.me?a=3&b=4');
assert.equal(state.params.a, '3');
assert.equal(state.params.b, '4');

state = new State('http://colla.me?a=3&b');
assert.equal(state.params.a, '3');
assert.equal(state.params.b, undefined);

1.3 如何解决中断的问题

assert 本身一旦失败就会中断进程,但是在测试的时候,我们想要全部测试一遍,并且知道其中哪些成功了,哪些失败了。对于每一条测试的结果,我们都需要输出,所以感觉需要有一个专门的测试helper function,来catch error。

test.js

const assert = require('assert');
const test = (title, spec) => {
    try {
        spec();
        console.log(`spec:${title}: ok`);
    } catch (e){
        console.log(`spec:${title}: fail`);
    }
};

test('when one param', () => {
    let state = new State('http://colla.me?a=3');
    assert.equal(state.params.a, '3');
});

test('when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    assert.equal(state.params.a, '3');
    assert.equal(state.params.b, '4');
});

test('when params not valid', () => {
    let state = new State('http://colla.me?a=3&b');
    assert.equal(state.params.a, '4');
    assert.equal(state.params.b, undefined);
});

实际测试一下,可以发现我们已经能得到很规整的测试结果了(最后一个失败的部分是故意写错测试用例的,w)。 error

1.4 让shell脚本知道测试结果

上面的test方法执行的时候,shell脚本是不知道成功或者失败的,如果我们用了CI的话,CI需要知道结果然后执行相应的操作,比如发成功或者失败的消息到团队聊天工具。这需要node.js退出进程的时候设置返回值,我们使用process.exitCode试试: https://nodejs.org/api/process.html#process_process_exitcode ,同时把这个test 库文件单独拆开。

test.js

'use strict';
/**
 * test helper
 * @param {title} title - spec title
 * @param {function} spec - the spec function
 * it console.log the result
 */
module.exports = (title, spec) => {
    try {
        spec();
        console.log(`✓ ${title}`);
    } catch (e){
        process.exitCode = 1;
        console.log(`✘ ${title}`);
    }
};

1.5 测试单独放在test/stateSpec.js

'use strict';
const State = require('../src/state.js');
const test = require('../lib/test.js');

test('should works when one param', () => {
    let state = new State('http://colla.me?a=3');
    console.assert(state.params.a === '3');
});

test('should works when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    console.assert(state.params.a === '3');
    console.assert(state.params.b === '4');
});

test('should ignore invalid param', () => {
    let state = new State('http://colla.me?a=3&b');
    console.assert(state.params.a === '4');
    console.assert(state.params.b === undefined);
});

test.js摇身一变,变成了一个类似测试框架的东西,我们用shell脚本测试一下是否正确处理了exit code。

test exit

当最后一个测试得到修正过后,$?将返回0,这样在CI等自动化环境中,测试的结果就可以监听了。

1.6 test自动注入

上述测试中,每次都需要在spec中require('/path/to/test.js'),作为纯粹的测试spec,这个应该自动化完成,也就是说用另外的命令来注入,比如如果有lib/unit.js的话,我们应该能够实现node lib/unit.js test/stateSpec.js,这样lib/unit.js可以package称为单独的命令。

unit.js

'use strict';
const fs = require('fs');

// accept second param to be spec file
let file = fs.readFileSync('./' + process.argv[2], 'utf8');
const Module = require('module');

// adjust folder
file = file.replace('require((.*)?)', 'require(../$1)');

new Module()._compile('\'use strict\';const test = require(\'./lib/unitTest.js\');' + file);

这里做的是,获取第二个参数(spec文件路径),然后读取文件内容,插入test.js的require, 然后_compile。这里Module的利用方式涉及到node.js的module系统,关于此有兴趣的话可以查看我的另外一篇文章「Node.js module的require是如何实现的

注意其中对spec中的路径进行了统一。

修改spec.js如下

const State = require('../src/state.js');

test('should works when one param', () => {
    let state = new State('http://colla.me?a=3');
    console.assert(state.params.a === '3');
});

test('should works when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    console.assert(state.params.a === '3');
    console.assert(state.params.b === '4');
});

test('should ignore invalid param', () => {
    let state = new State('http://colla.me?a=3&b');
    console.assert(state.params.a === '4');
    console.assert(state.params.b === undefined);
});

测试一下:

test unit

嗯,完美。

1.7 TDD -> BDD

TDD (Test Driven Development),测试驱动开发,BDD (Behavior Driven Development)。嗯,嘛我的开发经验也不是很丰富了,总之上述的spec显得很生硬,比如assert.equal。在TDD之上诞生了另外一种更加自然,更加人性化的方式来描述行为,这样测试内容简单易懂,组内通用,不是写代码的人也可以看懂,比如

// TDD
let state = new State('http://colla.me?a=3');
assert.equal(state.params.a, '3');

// BDD
let state = new State('http://colla.me?a=3');
expect(state.params.a).to.equal('3');

虽然看上去只是写法的不同,其代表的开发思想还是有很大区别的。BDD挺好。

为了实现上述的BDD,可以对test.js进行修改,但是这里就不重复造轮子了,用现成的框架比较好。这一类框架叫做断言框架, Assertion Library,有很多了,比如挺有人气的Chai。除了Chai之外,还有jasmine, power-test等,个人感觉选择某一类并不重要,只是写法不同而已,只要组内成员达成一致就OK。这里就用Chai重新书写(chai支持BDD/TDD两种方式)。 http://chaijs.com/

比如如下写法。

const State = require('./app.js').State;
const expect = require('chai').expect;

test('when one param', () => {
    let state = new State('http://colla.me?a=3');
    expect(state.params.a).to.equal('3');
});

test('when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    expect(state.params.a).to.equal('3');
    expect(state.params.b).to.equal('4');
});

test('when params not valid', () => {
    let state = new State('http://colla.me?a=3&b');
    expect(state.params.a).to.equal('5');
    expect(state.params.b).to.equal(undefined);
});

执行$> node lib/unit.js stateSpec.js应该能看到看到能得到相同的结果。

1.8 unit.js > mocha

/lib/unit.jstest.js实现的目的实际上是整理测试报告,但是结果显示还很简陋,我们可以做的更好,嗯,不过咱不要重复造轮子了,能实现类似功能的框架有很多,mocha就是其中一个,再比如 tape。以下也没有什么特别的理由,就用mocha重写好了,比如这样:

spec.js

const State = require('./app.js').State;
const expect = require('chai').expect;

describe('State should', () => {
    it('detect one param', () => {
        let state = new State('http://colla.me?a=3');
        expect(state.params.a).to.equal('3');
    });

    it('detect more than one params', () => {
        let state = new State('http://colla.me?a=3&b=4');
        expect(state.params.a).to.equal('3');
        expect(state.params.b).to.equal('4');
    });

    it('ignore invalid params', () => {
        let state = new State('http://colla.me?a=3&b');
        expect(state.params.a).to.equal('5');
        expect(state.params.b).to.equal(undefined);
    });
});

不过基本思想应该讲的差不多了。

1.8 总结一下,这就是单元测试(unit test)

应用仔细拆分为一个一个组件,方法,然后针对这些方法进行单个单个的测试,这就是unit test。从上面的例子可以看出,整个思想并不复杂,主要包括一个跑测试报告(mocha),和一个合适的断言库(Assertion Library)。测试可以写得事无巨细,但是实际开发中需要调平衡投入和产出,因为写测试也是要花时间的,把最重要的部分保证了就好,其他的就交给QA吧。这个问题最突出的倒不是我们上述的State,而是ClickButton,因为这个测试牵扯到用户行为,需要用浏览器环境,这个如何实现呢? 请接着看接下来的e2e test。

2 ClickButton (e2e)的测试

ClickButton中的逻辑是牵扯到浏览器的,所以测试的时候需要有一个浏览器环境才可以,所以测试代码也需要跑在浏览器里面。浏览器打开一个页面 => 然后测试 => 然后把结果返回给terminal,应该是可以的。

2.1 自己实现一个e2e测试

根据上述想法,需要有以下步骤:

    1. 开启一个临时的server
    1. 用浏览器打开测试用的html,其中含有测试spec.js
    1. spec.js 测试完毕后,把结果返回给terminal
    1. terminal处理结果,结束。

2.1.1 首先需要搭建一个本地server

const http = require('http');
const fs = require('fs');
const dir = '.';

const server = http.createServer((request, response) => {
    response.writeHead(200, {
        'Content-Type': 'text/html',
    });

    let file;
    let path = request.url;
    if (path === '/'){
        file = dir + '/e2e/test.html';
    } else if (/\.js$/.test(path)){
        file = dir + path;
    }
    response.end(file ? fs.readFileSync(file) : '');
}).listen(8888);

$> node e2e.js执行过后,用chrome访问http://localhost:8888/可以看到实际效果。

2.1.2 测试spec.js

首先我们想当然的写一下测试spec,然后再去实现。

spec.js

let state = new State(window.location.href);
let prevClick = state.params.click;
let clickbutton = new ClickButton(document.querySelector('button'));

test('first click ok', () => {
    clickbutton.onClick();
    state = new State(window.location.href);
    console.assert(state.params.click * 1 === (prevClick || 0) * 1 + 1);
});

test('second click ok', () => {
    prevClick = state.params.click;
    clickbutton.onClick();
    state = new State(window.location.href);
    console.assert(state.params.click * 1 === (prevClick || 0) * 1 + 2);
});

end();

2.1.3 test html

<button>click me</button>
<script src="/src/state.js"></script>
<script src="/src/clickButton.js"></script>

<script src="/lib/e2eTest.js"></script>
<script src="/test/clickButtonSpec.js"></script>

2.1.4 打开Chrome测试

$> node e2e.js之后,访问http://localhost:8888/,打开devTool,可以看到assertion failed error

这是因为assert部分故意写的失败的断言。修改一下就好。 console.assert(state.params.click == (prevClick || 0) * 1 + 1);

这样,e2e测试就成功了,但是这居然需要手动打开浏览器,太不智能了,而且测试结果也需要反应在terminal中。

2.1.4 自动打开Chrome

为了自动打开Chrome ,这里可以自己写command,但是有现成的用了就好了,在e2e.js最后添加如下代码。

//..
const open = require('open');
open('http://localhost:8888/');

2.1.6 汇报测试结果到terminal

这里就比较难办了,我想到的办法是,在local server中,增加一个专门测试的路由,spec.js测试完了过后,汇总结果通过这个请求传递给服务器。

嗯,首先和unit 测试一样,搞一个wrapper - test.js

lib/e2eTest.js

'use strict';
let result = {};

/**
 * test runnder
 * @param {string} title
 * @param {function} spec
 */
window.test = (title, spec) => {
    try {
        spec();
        result[title] = true;
    } catch (e){
        result[title] = false;
    }
};

/**
 * end e2e test, send result to local server
 */
window.end = () => {
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:8888/report', false);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(JSON.stringify(result));
    window.close();
}

test.js暴露两个方法,test()end(), end()将会汇总所有的测试结果,然后发送请求到report。

注意 end()中我们调用了window.close()方法,自动关闭窗口。

接下来在e2e.js处理report

'use strict';

const http = require('http');
const fs = require('fs');
const open = require('open');
const dir = '.';

let server;

server = http.createServer((request, response) => {
    response.writeHead(200, {
        'Content-Type': 'text/html',
    });

    let path = request.url.split('?')[0];

    if (path === '/report'){
        var body = [];
        request.on('data', (chunk) => {
            body.push(chunk);
        }).on('end', function() {
            body = Buffer.concat(body).toString();
            let result = JSON.parse(body);
            let failedCount = 0;
            Object.keys(result).forEach( (key) => {
                if (!result[key]){
                    failedCount++;
                }
                console.log(`${result[key] ? '✓' : '✘'} ${key}`);
            });

            response.end();
            process.exit(failedCount > 0 ? 1 : 0);
        });
    } else {
        let file;
        if (path === '/'){
            file = dir + '/e2e/test.html';
        } else if (/\.js$/.test(path)){
            file = dir + path;
        }
        response.end(file ? fs.readFileSync(file) : '');
    }
}).listen(8888);

open('http://localhost:8888/');

注意其中 /report的处理,首先通过stream的event:data来获取payload中的数据,然后分析report成功失败的结果,最后打印report,和退出程序。

2.1.7 最终测试。

执行如下命令,可以看到chrome自动访问测试页面,而且测试完毕后自动关闭了窗口,同时terminal中显示了测试结果。perfect。

e2e

不过真实情况下测试远比这复杂的多,包括用户事件处理,浏览器的额外方法,不同浏览器版本等。最简单的一点,以上方法根本不支持刷新的监测,因为spec.js的加载会在刷新后重新加载,无法对比上一次结果(如果要实现这个的话,估计要在test.html加入iframe页面进行统管,或者把状态发送给服务器进行处理)。另外BDD的支持也可以仿造前述unit test中的方法进行实现。

总之,重复造轮子的旅程就到这里了,以上内容已经有人给我们做好了,我们照着用就行了。

2.2 其他轮子

phatom.js 是一个无头的Webkit引擎,用在我们的e2e的话,chrome的管理就不需要了,不过无头的总感觉不靠谱,还是推荐有头的selenium 。selenium简单理解就是一个操纵浏览器进行自动化的工具, 刚刚进入了3.0版本, selenium的发展促进了各大浏览器的升级,现在Chrome, Safari, Firefox, IE都支持了webdriver。各自的浏览器貌似都有自己的driver server,如果只在单个浏览器上测试的话可以不用selenium server,

直接用 selenium/webdriver又太麻烦了,在其之上又有有一系列工具让其变的容易使用,比如 protractornightwatch之类的,本质上没有太大区别,感觉就是偏好问题了,protractor有浓浓的angular情节,nightwatch用过还不错。 另外最近出了一个testcafe,不需要webdriver什么的设置,用起来非常简单,但是在实际试了一下CI环境下不是很稳定,暂时不推荐。

嗯,这些轮子的使用就不多做介绍了,请自己摸索尝试。unit test 和 e2e test的基础就这些了吧的感觉。感谢阅读。