|
2019-05-17
用户登录接口测试的例子:
describe('user', () => { it('login by phone number', async (done) => { const res = await userLogin(axios, { phoneNumber: '15555555555', }); assert.ok(Array.isArray(res.data?.data)); done(); }); it('login by phone id', async (done) => { const res = await userLogin(axios, { id: 1, }); assert.ok(Array.isArray(res.data?.data)); done(); }); it('login by account & pwd', async (done) => { const res = await userLogin(axios as any, { account: 'test', pwd: 'test', }); assert.ok(Array.isArray(res.data?.data)); done(); }); it('login by account & pwd after change pwd', async (done) => { const res = await userLogin(axios as any, { account: '1', password: '2', }); assert.ok(Array.isArray(res.data?.data)); done(); }); }
例子说明:userLogin 调用了 Node 服务提供的用户登录接口,Node 服务会执行一些数据校验和预处理,然后调用另一个 HTTP API 执行登录。
我们发现,用户登录接口,有多种入参的可能,也对应不同的表现。隔离 Node server 登录接口背后依赖的外部 HTTP API(用 Dep0 指代),需要:记录 Dep0 的多条请求和返回记录,并和对应用例匹配。
这里采用 axios interceptor 做录制:
import { AxiosRequestConfig, AxiosResponse } from 'axios'; import 'url'; import fs from 'fs-extra'; import { getCtxLogger } from '../../services/context'; import crypto from 'crypto'; // 一些常量 const defaultFilePath = `${process.cwd()}/deps/net/`; const ext = '.json'; // 请求前记录请求入参 export const requestInterceptor = async (config: AxiosRequestConfig) => { const { url, method } = config; const urlObj = new URL(url as string); const { pathname } = urlObj; const filePath = getFilePathFromConfig(config); try { await fs.ensureFile(filePath); const rawCurrentContent = await fs.readFile(filePath, 'utf8'); let currentContent = {}; try { if (rawCurrentContent) { currentContent = JSON.parse(rawCurrentContent); } } catch (e) { console.error(`JSON.parse ${filePath} error, use empty object`, e); } if (!currentContent[pathname]) { currentContent[pathname] = {}; } if (!currentContent[pathname][method as string]) { currentContent[pathname][method as string] = {}; } const hash = getHashFromConfig(config); if (!currentContent[pathname][method as string][hash]) { currentContent[pathname][method as string][hash] = {}; } // force update currentContent[pathname][method as string][hash].request = config; await fs.writeJSON(filePath, currentContent); } catch (e) { console.error('deps/net requestInterceptor error', e); } return config; }; // 响应后,记录返回 export const responseInterceptor = async (response: AxiosResponse) => { const { config } = response; const { url, method } = config; const urlObj = new URL(url as string); const { host, pathname } = urlObj; const filePath = `${defaultFilePath}${host}${ext}`; try { await fs.ensureFile(filePath); const rawCurrentContent = await fs.readFile(filePath, 'utf8'); const currentContent = JSON.parse(rawCurrentContent); const hash = getHashFromConfig(config); // force update if (!currentContent[pathname][method as string][hash]) { currentContent[pathname][method as string][hash] = {}; } currentContent[pathname][method as string][hash].response = response.data; await fs.writeJSON(filePath, currentContent); } catch (e) { console.error('deps/net responseInterceptor error', e); } return response; }; // 辅助函数,从 config 对象中计算记录文件的路径 export function getFilePathFromConfig(config: AxiosRequestConfig): string { const { url } = config; const urlObj = new URL(url as string); const { host } = urlObj; const filePath = `${defaultFilePath}${host}${ext}`; return filePath; }
这里有一个需要注意的地方:
如何根据请求入参,计算出一个标识,在请求返回后,能够根据 response 再次计算出同样的标识,只有这样,才能把一次 HTTP 的请求和响应对应起来。完成这个功能的函数就是上面的 getHashFromConfig:
function getHashFromConfig(config: AxiosRequestConfig): string { const pure = { url: config.url, params: config.params, cookie: config.headers?.cookie, data: config.data, }; const strData = JSON.stringify(pure); // 对 config 求 hash const hash = crypto.createHash('md5').update(strData) .digest('hex'); return hash; }
这里没有直接对 AxiosRequestConfig 求 hash,而是仅提取其中几个关键字段 hash。因为测试中发现:
response 的 config 在请求前后,会存在字符串级别的不一致。
执行后记录的结果:
{ "/ws/district/v1/list": { "get": { "e4473f2e67634485d3b6defd93a502f1": { "request": { "transitional": { "silentJSONParsing": true, "forcedJSONParsing": true, "clarifyTimeoutError": false }, "transformRequest": [ null ], "transformResponse": [ null ], "timeout": 0, "xsrfCookieName": "XSRF-TOKEN", "xsrfHeaderName": "X-XSRF-TOKEN", "maxContentLength": -1, "maxBodyLength": -1, "headers": { "common": { "Accept": "application/json, text/plain, */*" }, "delete": {}, "get": {}, "head": {}, "post": { "Content-Type": "application/x-www-form-urlencoded" }, "put": { "Content-Type": "application/x-www-form-urlencoded" }, "patch": { "Content-Type": "application/x-www-form-urlencoded" } }, "method": "get", "url": "https://apis.map.qq.com/ws/district/v1/list" }, "response": { "status": 301, "message": "缺少必要字段key" } } } } }
至此,我们完成了 HTTP 请求的记录。而且还顺带解决了记录内容和用例匹配的问题:
根据请求入参 hash,就可以唯一标识一次同样入参的请求。
同样也是基于 axios interceptor 实现:
export const mockedRequest = axios.create(); // 参考: https://stackoverflow.com/questions/62686283/axios-how-to-intercept-and-respond-to-axios-request mockedRequest.interceptors.request.use((config) => { const { url, method } = config; const urlObj = new URL(url as string); const { pathname } = urlObj; throw { // 计算 hash,用于匹配记录的数据 hash: getHashFromConfig(config), filePath: getFilePathFromConfig(config), pathname, method, }; // <- this will stop request }); mockedRequest.interceptors.response.use( response => response, async (error) => { const hash = error?.hash; const filePath = error?.filePath; const { pathname, method, } = error; const rawCurrentContent = await fs.readFile(filePath, 'utf8'); let currentContent = {}; try { if (rawCurrentContent) { currentContent = JSON.parse(rawCurrentContent); } } catch (e) { console.error(`JSON.parse ${filePath} error, use empty object`, e); } const res = { data: currentContent[pathname][method][hash]?.response, }; return res; } // <- sends as successful response );
这里利用了 axios request interceptor throw 会终止请求的特性,重定向到读取本地记录的数据。
最后加上根据环境变量自动切是否执行录制:
// 用环境变量 RECORD,开启记录模式 if (process.env.RECORD) { request.interceptors.request.use(requestInterceptor); request.interceptors.response.use(responseInterceptor); }
其实,我们记录的外部 HTTP 接口的请求和响应数据,除了用于 mock,还可同时用于推导类型,免除手动书写 interface。
import mockData from '../deps/net/apis.map.qq.com.json'; d type ListResponse = typeof mockData['/ws/district/v1/list'].get.e4473f2e67634485d3b6defd93a502f1.response;
最后附上完整实现:https://github.com/xiaoshude/node-common
这里从应用内出发,实现 HTTP 依赖隔离,如果考虑通用方案,也可以尝试下直接覆写 Node http 模块的方法做流量拦截。
编辑:航网科技 来源:腾讯云 本文版权归原作者所有 转载请注明出处
微信扫一扫咨询客服
全国免费服务热线
0755-36300002