通过写测试来学习事件驱动设计

哈德韦

共 4267字,需浏览 9分钟

 · 2022-07-31

拉下一个项目到本地,第一步就是跑测试。没有测试那就得先写测试,确保后续迭代过程中不会改坏原有系统。


在写测试时,经常会需要隔离外部依赖,这可以通过一些 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})
// 事件的处理@OnEvent('xx事件')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 对象,甚至任何事件驱动的设计本质暴露出来:

  • 公开的事件处理函数注册接口,无非是事件“记住”什么事件对应哪些处理函数。这可以通过一个函数变量(最简单的情况),或者一个数组,或者一个哈希映射等等来实现。

  • 公开的事件触发接口,无非是让系统知道该去寻找哪个事件处理函数的一种通知机制罢了。



浏览 19
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报