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

在写测试时,经常会需要隔离外部依赖,这可以通过一些 Mock 库来实现。但是,除非有频繁的相同 Mock 逻辑,否则完全可以自行手写 Mock,这不仅可以减少引入第三方库,更可以通过手写 Mock 来学习外部依赖的设计。

尽管在测试中,没有使用到真正的依赖库,而是用了自己的一个假的实现,但是假的实现必须拥有同样的对外界面。又由于它是假的,于是它可以很简单,更方便看清楚最基本的设计。

实例

最近拉了一个自己写的老项目,其中一个场景,是测试登录失败:

假定输入了错误的用户名或者密码,点击登录,期待页面组件的状态中包含错误信息。

点击登录后,实现代码是使用** XMLHttpRequest** 向后端服务发送请求,传递用户名密码。那么在测试中,为了移除后端服务这个依赖,可以把 XMLHttpRequest 替换成一个假的实现,不需要让它真的发送网络请求,只需要接收以及返回测试需要的数据即可。

登录的实际实现

重点是这个 processForm 函数,它在用户提交表单后触发,收集组件中的用户名和密码,使用 XMLHttpRequest 构造一个 xhr 请求。它先设置好一个事件的回调,即 load 事件的回调,然后就发送这个 xhr了。在回调函数里异步拿到了服务器的返回响应,并针对不同的情况做不同的 UI 更新。

javascript 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 后,就可以在应用程序中这样使用:

typescript // 事件的发生和处理解藕 this.eventEmitter.emit(xx事件, {payload})

// 事件的处理 @OnEvent(xx事件) public async handleXX(payload) { ... }

那么这样的设计是怎么实现的呢?

测试的可贵

这种设计虽然处处可见,但如果不是因为要写测试,我还真的没有去思考过它的实现方式。所以写测试的可贵之处,不仅仅在于它能帮助我构建一个安全网,还可以逼迫我思考当前系统用到的设计。

通过最简单的实现搞懂事件驱动设计原理

为了能够模拟这个 XMLHttpRequest,我开始思考一个最简单的实现。既然它公开了一个添加事件回调函数的接口,那么它的内部一定需要维护一系列的事件回调函数。在最简单的场景下,一个事件对应一个回调函数(因为目前实现代码也没有对同一个事件添加多个回调函数);再简单一点,只支持一个事件(因为目前的实现场景只依赖这一个事件),那么,这个维护工作就简化成了一个可以保存回调函数变量。于是,有了这个雏形:

javascript const mockXhr = function () { }

mockXhr.prototype.addEventListener = function (_, callbackHandler) { this.func = callbackHandler }

另外,它还公开了一个用来触发事件发生的接口,这个接口实际上就是去调用事件注册好的回调函数而已。对于我要测试的这个场景,是希望能够触发登录失败的响应,于是,可以这样写:

javascript

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 的世界里,于是只要通过这样就行了:

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 对象,甚至任何事件驱动的设计本质暴露出来:

  • 公开的事件处理函数注册接口,无非是事件“记住”什么事件对应哪些处理函数。这可以通过一个函数变量(最简单的情况),或者一个数组,或者一个哈希映射等等来实现。
  • 公开的事件触发接口,无非是让系统知道该去寻找哪个事件处理函数的一种通知机制罢了。