如何在单元测试中为HTTP请求快速打桩
在前端开发中,可以使用 MockJs 之类的工具来拦截 Ajax 请求,以便实现前端和后端的并行开发;但拦截 Ajax 请求有一些局限性,比如在 NodeJs 环境中,HTTP 请求并不是 Ajax 请求,那么使用 MockJs 之类的工具进行拦截就会有问题,同理,单元测试中也会遇到同样的问题。本篇博文将主要以单元测试的请求打桩为例,介绍一种相对通用的请求拦截方案。
HTTP 请求的拦截思路
首先,在使用以 MockJs 为首的前端 Ajax 请求 mock 时,这类工具可以很方便的拦截 BOM 的原生请求,这种方式仅限于浏览器环境;在如今 Node 盛行的时代,如果需要拦截 Node 环境下的 HTTP 请求,就不能使用浏览器那一套了。
Node 环境下的 HTTP 请求可以通过 http
模块来发出来,但是这并不是最终的路子。众所周知的,HTTP 协议是建立在 TCP/IP 协议之上的,HTTP 请求最终走的还是 TCP 协议,所以如果在 TCP 协议进行监听拦截,至少在 Node 环境中是一个比较终极的方案,但这种方式有点小题大做,有种杀鸡用牛刀的感觉。
另外一种方案则相对简单,偏上层应用,也是我今天要介绍的方案。
我们都知道的,目前无论前端开发还是 Node 环境下开发,我们很少回去写原生的 Ajax 或者 http 的 request 的,而是会使用一些开源中间库,例如优秀的 axios
,这是一种广泛使用的 HTTP 请求模块,可以在浏览器和 Node 环境下使用。那么我们尝试去拦截 axios 的请求函数,是否就能实现对请求的 Mock 呢?
答案是肯定的。
所以综上来看,整理起来,如果需要拦截 HTTP 请求,大致可以分为三种思路:
- 拦截 HTTP 请求的原生发起端,如浏览器环境的 XMLHttpRequest 和 Node 环境下的 http 模块;
- 直接监听 TCP 协议通道的数据;
- 拦截上层调用模块,如 axios 等;
从成本上来看,拦截原生的 XMLHttpRequest
或者 http
属于中等成本的,MockJs 使用的是重写 XMLHttpRequest
以实现拦截的方案;直接监听 TCP 通道的数据成本最高,很不划算;拦截应用层的模块成本最低,因为这类偏向应用层的模块一般都兼容了浏览器环境和 Node 环境。
实现方案
首先从具体场景出发。
开发此模块的目的是为了解决单元测试中请求打桩问题,此次项目的单元测试有 Node 端和浏览器端,所以单纯使用 MockJs 已经不太能满足要求,另外一个痛点是 MockJs 没有 removeMock 的方案,在前端开发中一旦打桩完成这个桩将一直有效,某些常见下不是很灵活。
基于上面的问题,我觉得从 axios 模块下手,开发一个模块,来挂载到 axios 模块上,以便可以实现跨端轻松打桩。
基本的功能:
- 已正则作为 URL 拦截规则;
- response 可以自定义,且可以随时更改;
- 可以将桩机关联到其他模块,如 axios ;
- 可以清除或者移除对某个 URL 的监听;
下来看具体的实现。
URL 匹配
URL 匹配采用正则方案,用户在设置匹配的 URL 时可以传字符串或者正则,这点和 MockJs 类似。
/**
* 生成一个URL的正则匹配
*
* @param url 地址或正则
*/
function createUrlRegExp(url: string | RegExp): RegExp {
if (typeof url === 'string') {
url = url.toString().replace('/', '\\/');
url = url.replace('?', '\\?');
return new RegExp(`(.*)?${url}`, 'i');
}
return url;
}
createUrlRegExp
方法用于生成一个 URL 的正则对象,用于后续的正则匹配。
需要注意的是,如果传递的 url 是字符串,这里就会采用包含匹配,并不是全等,类似于使用 indexOf
的效果;如果 url
参数本身就是一个正则对象,则原样返回,所以如果用户需要全匹配,完全可以通过写一个正则来实现。
请求类型匹配
这里并没有单独请求请求类型匹配,而是放到了一个类中去完成。
class RequestMock {
/** response mocked map */
_mockMap: object
/** mounted Function */
_source: Function
/** bound object */
_markObj: object
/** property method of bound object */
_markMethod: string
constructor(obj: object, method: string) {
this._mockMap = {};
this._source = null;
if (obj && method) {
this.mount(obj, method);
}
}
}
RequestMock
是一个类,用于实例化一个确定类似的请求拦截器,实例化时需要传入要挂载到的对象及其方法。
例如,将 Mock 实例挂载到 axios
的 get
方法上:
const axios = require('axios');
const reqMock = new RequestMock(axios, 'get');
此时 reqMock
就是一个挂载到 axios.get 方法上的 Mock 实例,之后用户调用 axios.get 方法时将由 reqMock
对象来接管。
具体实现细节可见下文。
Response的自定义和混入
如果使用 MockJs ,就可以发现它所提供的 Random 模块非常强大,可以随机生成常见的各种类型的数据,但另一个局限性在于不便于单元测试。我们在做单元测试时,需要测试某个模块对返回值的处理逻辑,希望可以快速定义某几个字段的值,以便能触发逻辑的判断,在之前是大概率需要重新设置 Mock Response 的,但现在不用了,因为你随时可以向返回值混入你想要的数据。
举个例子,某个接口的请求数据你已经定义好了,在请求正常的情况下,它可以这样返回:
{
"code": 0,
"msg": "ok",
"data": [
{
name: "Tom",
age: 19
},
{
name: "Jack",
age: 23
}
]
}
假设你现在有一个模块,需要测试请求失败的逻辑(返回值的 code 不为 0),那么混入就这么做:
const resError = {
code: 403,
msg: 'error',
};
reqMock.mock(userList.url, userList.response, { ...resError });
这里要介绍一下 mock
方法,它是用来设置请求拦截的,原型为:
function mock(url: RegExp | string, response: any, mixinData?: object): void
如果传递了需要 Mixin 的数据,那么最后响应的 response 中将会包含混入值。
这样做的一个好处是,我们可以在不改变正常响应定义的情况下,快速的修改响应的数据。
数据混入的实现
混入的实现主要借助了下面的三个方法。
/**
* 生成一个返回值对象
*
* @param baseRep 基础响应
* @param mixed 混入的对象
*/
function createResponse<T>(baseRep: T, mixed?: object): T {
if (!isObject(mixed) || !mixed) {
return baseRep;
}
let baseCopy = { ...baseRep };
for (let key in mixed) {
mixin(baseCopy, key, mixed[key]);
}
return baseCopy;
}
createResponse
用于生成响应数据,如果预定义的响应不是一个对象时,将直接返回,不处理混入。如果是对象,那么调用 mixin
方法进行逐层混入。
/**
* 将数据混入到原对象中
*
* @param obj 原对象
* @param position 混入的位置
* @param mixdata 需要混入的值
* @returns
*/
function mixin<T>(obj: T, position: string, mixdata: any): T {
if (!isObject(obj) || !position) {
return obj;
}
if (!/\./.test(position)) {
obj[position] = obj[position] ? assignIn(obj[position], mixdata) : mixdata;
return obj;
}
const key = position.split('.')[0];
const newPos = position.replace(new RegExp(`^${key}\.`), '');
obj[key] = obj[key] ? mixin(obj[key], newPos, mixdata) : mixdata;
return obj;
}
mixin
方法用于处理混入逻辑,其 position 是混入的位置,支持深度混入,所以 position
可以是类似 a.b.c 这样的结构,数据最终会被直接混入到 c
中。
在处理混入时也需要考虑常见的三种类似,如果是数组,那么混入就代表着合并,如果是对象,混入就代表的混合,如果是普通类型,混入则意味着替代。
上面的逻辑在 assignIn
方法中实现:
/**
* 合入
*
* @param obj 原对象
* @param data 合人值
*/
function assignIn(obj: any, data: any) {
if (isArray(obj) && isArray(data)) {
obj = obj.concat(data);
return obj;
}
if (isObject(obj) && isObject(data)) {
obj = Object.assign(obj, data);
return obj;
}
return obj = data;
}
设置 URL 拦截的实现
上文已有介绍,URL 的拦截是通过 mock
方法来实现的,其实现如下:
/**
* 添加一个对传入 URL 的 mock
*
* @param url 地址
* @param response 响应值
* @param mixinData 混入值
*/
mock(url: RegExp | string, response: any, mixinData?: object) {
const regUrl = createUrlRegExp(url);
this._mockMap[regUrl.toString()] = createResponse(response, mixinData);
}
其实现非常简单,就是对传入的 URL 转正则,然后保存在一个键值对中。
关键的实现在于下面的 mount
方法,该方法用于挂载当前实例,实现如下:
/**
* 绑定mock到指定对象的方法上
*
* @param obj 需绑定的对象
* @param method 需绑定的方法
*/
mount(obj: object, method: string) {
if (!obj || !method || !obj[method] || !isFunction(obj[method])) {
throw new Error(`The bound object must have a property method that exists`);
}
this._markObj = obj;
this._markMethod = method;
this._source = obj[method];
obj[method] = (url: string, params?: object): Promise<any> => {
for (let key in this._mockMap) {
const response = this._mockMap[key];
key = key.replace(/^\/|\/i|\//g, '');
const regUrl = new RegExp(key);
if (regUrl.test(url)) {
return Promise.resolve(response);
}
}
return this._source(url, params);
};
}
该方法要求传入的传输必须是对象和字符串,即要挂载在哪个对象的什么方法上。方法的具体实现逻辑其实是一个对被挂载对象指定方法的重写。例如,如果实例化是传入的是 axios
和 get
,则表示将当前 Mock 实例挂载到 axios.get
方法上。
拦截的移除和还原
移除则表示不再监听某个 URL 的请求,其实现对应为删除键值对中的特定键值。
/**
* 移除对传入 URL 的 mock
*
* @param url 地址
*/
remove(url: string | RegExp) {
const regUrl = createUrlRegExp(url);
try {
delete this._mockMap[regUrl.toString()];
}
catch (err) {}
}
而还原则意味着将原来接管的对象的方法进行回滚。
/**
* 解除绑定
*/
unmount() {
this._markObj[this._markMethod] = this._source;
this._source = null;
}
使用示例
上面主要介绍了具体的实现方案,下面来介绍如果使用,总结起来也就三行。
假设有一个 mock 数据如下:
const userList: {
url: '/app/user/list',
response: {
data: [
{
name: 'Tom',
age: 18
},
{
name: 'Jack',
age: 20
}
]
}
}
那么使用这个数据来实现特定URL的拦截可以这样写:
// 第一步,实例化
const reqMock = new RequestMock(axios, 'get');
// 第二步,设置 URL Mock
reqMock.mock(userList.url, userList.response);
// 第三步,发送请求
let result = await axios.get('/app/user/list?code-123456');
由于 userList.url
正则可以完成对 /app/user/list?code-123456
的匹配,所以这条请求就被拦截下来了,最后的返回值就是上面的 response
。
最后,如果需要用回 axios.get 方法,可以调用 unmount 进行卸载。
// 移除对 axios.get 的挂载
reqMock.unmount();
之后再使用 axios.get
,就可以按照正常方式发生请求了。
在单元测试中的使用
总体使用跟上面一样,以 jest
框架为例,直接使用一个示例来说明:
describe('Sinple demo', () => {
const reqMock = new RequestMock(axios, 'get');
afterAll(() => {
reqMock.unmount();
});
afterEach(() => {
reqMock.clear();
});
it('[GET] should return hello world', async () => {
const response = 'hello wolrd'
reqMock.mock(/(.*)\/test\/hello/, response);
const result = await axios.get(`/test/hello?response=world`);
expect(result).toBe(response);
});
it('[GET] should return success code', async () => {
const { userList } = mockData;
reqMock.mock(userList.url, userList.response, { ...resSuccess });
const result = await axios.get(userList.url);
expect(result.code).toBe(0);
});
});
Git 地址
项目的 Git 地址:request-mock-ut