通过写测试来学习事件驱动设计
拉下一个项目到本地,第一步就是跑测试。没有测试那就得先写测试,确保后续迭代过程中不会改坏原有系统。
在写测试时,经常会需要隔离外部依赖,这可以通过一些 Mock 库来实现。但是,除非有频繁的相同 Mock 逻辑,否则完全可以自行手写 Mock,这不仅可以减少引入第三方库,更可以通过手写 Mock 来学习外部依赖的设计。
尽管在测试中,没有使用到真正的依赖库,而是用了自己的一个假的实现,但是假的实现必须拥有同样的对外界面。又由于它是假的,于是它可以很简单,更方便看清楚最基本的设计。
实例
最近拉了一个自己写的老项目,其中一个场景,是测试登录失败:
假定输入了错误的用户名或者密码,点击登录,期待页面组件的状态中包含错误信息。
点击登录后,实现代码是使用 XMLHttpRequest 向后端服务发送请求,传递用户名密码。那么在测试中,为了移除后端服务这个依赖,可以把 XMLHttpRequest 替换成一个假的实现,不需要让它真的发送网络请求,只需要接收以及返回测试需要的数据即可。
登录的实际实现
重点是这个 processForm 函数,它在用户提交表单后触发,收集组件中的用户名和密码,使用 XMLHttpRequest 构造一个 xhr 请求。它先设置好一个事件的回调,即 load 事件的回调,然后就发送这个 xhr了。在回调函数里异步拿到了服务器的返回响应,并针对不同的情况做不同的 UI 更新。
processForm(event) {
event.preventDefault();
const username = encodeURIComponent(this.state.user.name);
const password = encodeURIComponent(this.state.user.password);
const formData = `username=${username}&password=${password}&returnUrl=/admin/orders`;
let self = this;
const xhr = new XMLHttpRequest();
xhr.open('post', '/admin/api/sign-in');
console.log('signing requesting...')
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
console.log('xhr = ', xhr);
try {
let json = JSON.parse(xhr.response);
self.setState({
errors: {},
successMessage: 'welcome'
});
Auth.authenticateUser(json.token);
let returnUrl = json.returnUrl || '/';
console.log(returnUrl);
browserHistory.push(returnUrl);
console.log('should redirect');
} catch (ex) {
console.error('ex = ', ex);
const errors = ex.message || JSON.stringify(ex);
self.setState({
errors
});
}
} else {
console.error('err = ', xhr.response);
const errors = xhr.response.errors ? xhr.response.errors : {};
errors.summary = xhr.response.message || xhr.response;
self.setState({
errors
});
}
});
xhr.send(formData);
}
异步事件驱动设计分析
这是一个典型的异步事件驱动代码实例。因为网络请求比较耗时,所以采用回调的设计是很合理也很高效的。除了 XMLHttpRequest,还有很多其他的对象,都遵循了这种设计,即通过一个事件监听函数,来接收某些事件的回调函数,即预先指定,当某某事件发生时,就该做些什么响应。然后,通过一些某件触发函数,用来通知系统,什么事件已触发。
这样的设计之所以是合理并高效的,就是因为它将事件的处理和事件的触发做了漂亮的解藕,可以将事件的触发和处理分别放在不同的地方维护,同时这个回调机制使得不必等待事件的发生,只需要预先告诉系统当事件发生时要做什么处理就好,告诉完了就可以继续去干其他的事情。
这个实例是一个很老的项目,用了 XMLHttpRequest 这个对象。目前 nodejs 的 event emitter,以及 Nest Js 库中的 EventEmitter2,其实都是这样的设计。比如 Nest Js 中的 EventEmitter2,在 Module 中注入 EventEmitterModule 后,就可以在应用程序中这样使用:
// 事件的发生和处理解藕
this.eventEmitter.emit('xx事件', {payload})
// 事件的处理
public async handleXX(payload) {
...
}
那么这样的设计是怎么实现的呢?
测试的可贵
这种设计虽然处处可见,但如果不是因为要写测试,我还真的没有去思考过它的实现方式。所以写测试的可贵之处,不仅仅在于它能帮助我构建一个安全网,还可以逼迫我思考当前系统用到的设计。
通过最简单的实现搞懂事件驱动设计原理
为了能够模拟这个 XMLHttpRequest,我开始思考一个最简单的实现。既然它公开了一个添加事件回调函数的接口,那么它的内部一定需要维护一系列的事件回调函数。在最简单的场景下,一个事件对应一个回调函数(因为目前实现代码也没有对同一个事件添加多个回调函数);再简单一点,只支持一个事件(因为目前的实现场景只依赖这一个事件),那么,这个维护工作就简化成了一个可以保存回调函数变量。于是,有了这个雏形:
const mockXhr = function () {
}
mockXhr.prototype.addEventListener = function (_, callbackHandler) {
this.func = callbackHandler
}
另外,它还公开了一个用来触发事件发生的接口,这个接口实际上就是去调用事件注册好的回调函数而已。对于我要测试的这个场景,是希望能够触发登录失败的响应,于是,可以这样写:
xhrMock.prototype.send = function () {
this.status = 401;
this.response = authErrorMessage;
this.func();
}
整个 XMLHttpRequest 的 mock 对象,虽然简单到简陋,但是通过手工捏造了一个假的实现,XMLHttpRequest 瞬间不再神秘了。有意思的是,这样做完全可行,测试跑得欢畅无比。源代码见:https://github.com/Jeff-Tian/v/blob/master/client/tests/admin/Auth.test.js
当然,要注意在跑测试前用 mock 对象替换原来的 XMLHttpRequest。由于我们在 JavaScript 的世界里,于是只要通过这样就行了:
window.XMLHttpRequest = xhrMock;
it('should fail login when input is bad', async () => {
const username = wrapper.find('input[name="name"]');
username.simulate('change', {
target: {name: 'name', value: 'badguy'}
});
const password = wrapper.find('input[name="password"]');
password.simulate('change', {
target: {name: 'password', value: 'nopass2'}
});
wrapper.find('form').simulate('submit', {
preventDefault() {
}
});
wrapper.update();
expect(wrapper.state().errors).toEqual({summary: authErrorMessage});
})
总结
写测试逼迫我去思考代码的设计,这不仅仅对自己的代码有效。哪怕是第三方代码,由于要去模拟它,在不使用其他模拟框架时,就需要思考一下第三方代码的实现了,并且可以通过写最简单的实现,来剖析它的内部原理,让它们不再神秘。
本文通过一个具体的例子,将 XMLHttpRequest 对象,甚至任何事件驱动的设计本质暴露出来:
公开的事件处理函数注册接口,无非是事件“记住”什么事件对应哪些处理函数。这可以通过一个函数变量(最简单的情况),或者一个数组,或者一个哈希映射等等来实现。
公开的事件触发接口,无非是让系统知道该去寻找哪个事件处理函数的一种通知机制罢了。