单页应用详解(Single page apps in depth)
4.测试
本文为译文,原文:http://singlepageappbook.com/maintainability3.html
TDD?让代码可以测试最好的方式就是编写代码时要与first-TDD风格,TDD归结为:
TDD是一系列编写代码的规则:写一个失败的测试(red),然后用足够的代码让它通过(green),然后重构需要重构的地方。
在这章中,我们将讨论如何用Mocha来为项目建立测试,如何依赖注入你的CommonJS模块,如何测试一段异步的代码。如果没有了解过TDD,可以看看Kent Beck's book , Michael Feather's book。
为什么要写测试?
测试驱动的开发不值得是因为它会catche错误,但是它能改变你思考模块之间的接口。编写代码前写测试会影响你考虑模块的接口,和模块间的耦合。它提供了一种安全的推动重构的方式,而且记录下了系统的预期行为。
大多数情况下,你没有完全理解系统就开始写。写出来的只是一个草稿。你想改进代码又不影响现有的代码。这就是测试的目的:告诉你重构的时候哪些预期需要执行。
测试什么?
测试驱动开发以为这测试应该引导开发。在开发一个新功能的时候,我通常把测试当成todo,在不知道代码会在测试的时候会是什么样之前是不写代码的。测试就是合同:这是模块需要给外部提供的一部分。
我发现测试的最大价值来自于纯粹的逻辑测试和其它边缘情况下的案例。我趋向于不测试内部细节。我也避免被测试的东西很难建立测试,测试是一个工具,而不是一个目标。views,我一般测试逻辑,并让它可以独立测试不依赖于显式属性。
测试框架
除了Jasmine之外,任何测试框架都是可以的。Jasmine异步测试非常糟糕,它需要很多样板代码( amount of boilerplate code)。
通常有3种测试风格:
- BDD:describe(foo) .. before() .. it()
- TDD:suite(foo) .. sexports['suite'] = { before: f() .. 'foo should': f() }etup() .. test(bar)
- exports:exports['suite'] = { before: f() .. 'foo should': f() }
[~] mkdir example
[~] cd example
[example] npm init
Package name: (example)
Description: Example system
Package version: (0.0.0)
Project homepage: (none)
Project git repository: (none)
...
[example] npm install --save-dev mocha
我喜欢exports的测试风格:
var assert = require('assert'),
Model = require('../lib/model.js');
exports['can check whether a key is set'] = function(done) {
var model = new Model();
assert.ok(!model.has('foo'));
model.set('foo', 'bar');
assert.ok(model.has('foo'));
done();
};
注意done()方法。要调用这个方法通知测试程序这个测试已经完成。这让一步测试非常简单,你可以在一步方法调用最后调用done()。
exports['given a foo'] = {
before: function(done) {
this.foo = new Foo().connect();
done();
},
after: function(done) {
this.foo.disconnect();
done();
},
'can check whether a key is set': function() {
// ...
}
};
exports['given a foo'] = {
beforeEach: function(done) {
// ...
},
'when bar is set': {
beforeEach: function(done) {
// ...
},
'can execute baz': function(done) {
// ...
}
}
};
基本的断言
- assert.ok(value, [message])
- assert.equal(actual, expected, [message])
- assert.deepEqual(actual, expected, [message])
TESTS += test/model.test.js
test:
@./node_modules/.bin/mocha \
--ui exports \
--reporter list \
--slow 2000ms \
--bail \
$(TESTS)
.PHONY: test
执行”make test“就可以运行了。
// if this module is the script being run, then run the tests:
if (module == require.main) {
var mocha = require('child_process').spawn('mocha', [ '--colors', '--ui',
'exports', '--reporter', 'spec', __filename ]);
mocha.stdout.pipe(process.stdout);
mocha.stderr.pipe(process.stderr);
}
测试模块之间的交互
单元测试只能在一次测试一个模块。每个单元测试执行一个模块的测试。直接输入(比如方法的参数)传入到模块。只要值被返回,断言就会核查直接输出。
但是,更复杂的模块可能会用到其它模块,比如通过函数读取数据库,写入数据库。
如果想把依赖(比如数据库模块)换成另一个更早用于测试为目的的东西,有几种好处。
- 可以捕获直接输出和控制输入。
- 可以模拟错误条件,比如timeout,练级错误。
- 可以避免比较难建立外部依赖,比如数据库和外部API。
exports['it should be called'] = function(done) {
var called = false,
old = Foo.doIt;
Foo.doIt = function(callback) {
called = true;
callback('hello world');
};
// Assume Bar calls Foo.doIt
Bar.baz(function(result)) {
console.log(result);
assert.ok(called);
done();
});
};
在跟复杂的案例中,如果你想替换整个后端的对象。有2种方案:构造参数和模块替换
构造参数
用配置来通过依赖:
function Channel(options) {
this.backend = options.backend || require('persistence');
};
Channel.prototype.publish = function(message) {
this.backend.send(message);
};
module.exports = Channel;
比如上面这段代码,写测试的时候,用一个测试对象来替代真是对象传进去。
var MockPersistence = require('mock_persistence'),
Channel = require('./channel');
var c = new Channel({ backend: MockPersistence });
代码会更杂乱,必须写this.backend.send而不能写Persistence.send,而且仅当测试的时候才传入这个option
模块替换
另一种方法是写一个function改变模块中依赖的值,比如:
var Persistence = require('persistence'); function Channel() { }; Channel.prototype.publish = function(message) { Persistence.send(message); }; Channel._setBackend = function(backend) { Persistence = backend; }; module.exports = Channel;_setBackend方法就是用来替换内部模块
Persistence
。
写测试的时候用require()一个模块,然后调用setBackend()来注入依赖的模块。
// using in test
var MockPersistence = require('mock_persistence'),
Channel = require('./channel');
exports['given foo'] = {
before: function(done) {
// inject dependency
Channel._setBackend(MockPersistence);
},
after: function(done) {
Channel._setBackend(require('persistence'));
},
// ...
}
var c = new Channel();
还有一些其它的技术,包括创建工厂类(这会让common部分变得更复杂),重定义require(可以使用Node's VM API)。但我更喜欢上面的方式,我有一种更抽象的方法,但事实证明不值得那么做,_setBackend()就是解决这个问题最简单的方式。
测试异步代码
有3种方式:
- 写一个工作流
- 等待事件,当预期条件完成了继续
- 记录事件并断言
exports['can read a status'] = function(done) {
var client = this.client;
client.status('item/21').get(function(value) {
assert.deepEqual(value, []);
client.status('item/21').set('bar', function() {
client.status('item/21').get(function(message) {
assert.deepEqual(message.value, [ 'bar' ]);
done();
});
});
});
};
Node.js EventEmitter | jQuery | |
Attach a callback to an event | .on(event, callback) / .addListener(event, callback) | .bind(eventType, handler) (1.0) / .on(event, callback) (1.7) |
Trigger an event | .emit(event, data, ...) | .trigger(event, data, ...) |
Remove a callback | .removeListener(event, callback) | .unbind(event, callback) / .off(event, callback) |
Add a callback that is triggered once, then removed | .once(event, callback) | .one(event, callback) |
- 如果用EE.once(),必须手动处理misses和手动计算。
- 如果使用EE.on(),必须在测试的最后手动detach,而且需要更复杂的计算。
EventEmitter.when = function(event, callback) {
var self = this;
function check() {
if(callback.apply(this, arguments)) {
self.removeListener(event, check);
}
}
check.listener = callback;
self.on(event, check);
return this;
};
基本功EE.once()差不多,接受一个事件和回调。不同的是,callback的返回值依赖于这个callback是否removed。
exports['can subscribe'] = function(done) {
var client = this.client;
this.backend.when('subscribe', function(client, msg) {
var match = (msg.op == 'subscribe' && msg.to == 'foo');
if (match) {
assert.equal('subscribe', msg.op);
assert.equal('foo', msg.to);
done();
}
return match;
});
client.connect();
client.subscribe('foo');
};
exports['doIt sends a b c'] = function(done) {
var received = [];
client.on('foo', function(msg) {
received.push(msg);
});
client.doIt();
assert.ok(received.some(function(result) { return result == 'a'; }));
assert.ok(received.some(function(result) { return result == 'b'; }));
assert.ok(received.some(function(result) { return result == 'c'; }));
done();
};
DOM或者一些难以模拟的依赖,就可以把我们调用的函数替换成另外一个。
exports['doIt sends a b c'] = function(done) {
var received = [],
old = jQuery.foo;
jQuery.foo = function() {
received.push(arguments);
old.apply(this, Array.prototype.slice(arguments));
});
jQuery.doIt();
assert.ok(received.some(function(result) { return result[1] == 'a'; }));
assert.ok(received.some(function(result) { return result[1] == 'b'; }));
done();
};
只要替换成一个函数,然后在函数里调用原来的函数。