||

如何在单元测试中为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 实例挂载到 axiosget 方法上:

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);
  };
}

该方法要求传入的传输必须是对象和字符串,即要挂载在哪个对象的什么方法上。方法的具体实现逻辑其实是一个对被挂载对象指定方法的重写。例如,如果实例化是传入的是 axiosget ,则表示将当前 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

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注