编写接口请求库单元测试与 E2E 测试的思考
最近在写适配 Mx Space Server 的 JS SDK。因为想写一个正式一点的库,以后真正能派的上用场的,所以写的时候尽量严谨一点。所以单测和 E2E 也是非常重要。
架构设计
先说说我这个接口库是怎么封装了,然后再说怎么去测试。首先我采用的是适配器模式,也就是不依赖任何一个第三方请求库,你可以用 axios、ky、umi-request、fetch 任何一个库,只需要编写一个符合接口标准的适配器。这里以 axios 为例。
配适器接口如下,目前比较简单。
export interface IRequestAdapter<T = any> {
default: T
get<P = unknown>(
url: string,
options?: RequestOptions,
): RequestResponseType<P>
post<P = unknown>(
url: string,
options?: RequestOptions,
): RequestResponseType<P>
patch<P = unknown>(
url: string,
options?: RequestOptions,
): RequestResponseType<P>
delete<P = unknown>(
url: string,
options?: RequestOptions,
): RequestResponseType<P>
put<P = unknown>(
url: string,
options?: RequestOptions,
): RequestResponseType<P>
}
实现 axios-adaptor 如下:
import axios, { AxiosInstance } from 'axios'
import { IRequestAdapter } from '~/interfaces/instance'
const $http = axios.create({})
// ignore axios `method` declare not assignable to `Method`
export const axiosAdaptor: IRequestAdapter<AxiosInstance> = {
get default() {
return $http
},
get(url, options) {
// @ts-ignore
return $http.get(url, options)
},
post(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.post(url, data, config)
},
put(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.put(url, data, config)
},
delete(url, options) {
const { ...config } = options || {}
// @ts-ignore
return $http.delete(url, config)
},
patch(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.patch(url, data, config)
},
}
然后在构造 client 的时候要注入 adaptor。如下:
const client = createClient(axiosAdaptor)(endpoint)
client.post.post.getList(page, 10, { year }).then((data) => {
// do anything
})
注入 adaptor 后,所有请求方法将使用 adaptor 中的相关方法。
这样做的好处是比较灵活,适用各类库,体积也能做到比较小。类似的 NestJS 等框架也是用了适配器模式,所以 NestJS 可以灵活选择 Express、Koa、Fastify 等。
坏处就是需要编写适配器,对新手来说可能不太友好,但是可以提供默认适配器去缓解这个问题。其次是适配器中方法返回类型是一定的,如错误的使用 axios 的 interceptor 可能会导致出现问题。
Unit Test
再说说单测,一般接口库也主要做这类测试比较多,因为单测不需要实际去访问接口,都是用 mock 的方式去伪造一个数据,而用 Jest 的话就直接 spyOn 去 mock 掉整个请求方法了。
这里用 axios 为默认适配器,那么就是在测试中 mock 掉 axios 的请求方法(axios.get, axios.post, ...)因为 axios 的逻辑你是不需要关心也不需要测试的。你只需要测试自己的业务逻辑就行了。
而对于这个库而言只需要测试有没有注入 adaptor 后,用 adaptor 请求数据之后有没有拿到了正确的值。如图所示,只需要测试 core 的逻辑,也就是注入 adaptor 之后有没有正确使用 adaptor 去请求,以及用 adaptor 请求拿到数据之后有没有正确处理数据。而关于请求了啥数据,并不关心,所以直接 mock 掉 axios 这层。
所以测试可以这样去写:
describe('client `get` method', () => {
afterEach(() => {
jest.resetAllMocks()
})
test('case 1', async () => {
jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
if (url === 'http://127.0.0.1:2323/a/a?foo=bar') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.a.get({ params: { foo: 'bar' } })
expect(data).toStrictEqual({ ok: 1 })
})
test('case 2', async () => {
jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
if (url === 'http://127.0.0.1:2323/a/a') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.a.get()
expect(data).toStrictEqual({ ok: 1 })
{
jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
if (url === 'http://127.0.0.1:2323/a/b') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.b.get()
expect(data).toStrictEqual({ ok: 1 })
}
})
})
如上,直接用 Jest spyOn 掉了 adaptor 的 get 方法,而要测试的则是 core 层有没有正确使用 adaptor 访问了正确的路径。所以在 mockImplementation 中,判断了是不是这个这个 url。
以上则是单测中的一环,client - adaptor - core 的测试。
然后说说单个接口怎么去写单测。我感觉这里其实没有什么必要去写。但是写了还是写一下,我也不知道有什么好的办法。还是使用 mock 的方法 mock 掉 adaptor 的请求返回。简单说说就是这样写了。
比如测试接口 /comments/:id
:
describe('test note client', () => {
const client = mockRequestInstance(CommentController)
test('get comment by id', async () => {
const mocked = mockResponse('/comments/11111', {
ref_type: 'Page',
state: 1,
children: [],
comments_index: 1,
id: '6188b80b6290547080c9e1f3',
author: 'yss',
text: '做的框架模板不错. (•౪• ) ',
url: 'https://gitee.com/kmyss/',
key: '#26',
ref: '5e0318319332d06503619337',
created: '2021-11-08T05:39:23.010Z',
avatar:
'https://sdn.geekzu.org/avatar/8675fa376c044b0d93a23374549c4248?d=retro',
})
const data = await client.comment.getById('11111')
expect(data).toEqual(mocked)
})
}
这边主要就是测试这个方法中请求的路径有没有写对了,但是非常关键的是用例中的路径一定要写对,上面那个的话就是 /comments/11111
,mockResponse
是我封装的一个测试方法。具体参考:@mx-space/api-client:__test__/helper
E2E test
E2E 是点对点测试,是需要去真实访问接口的,这也是最接近用户实际开发体验的测试,也就是说不 mock 掉 adaptor,也不在业务层用假数据的。当然假数据还是要用的,只是需要起一个额外的服务器去挂数据,以便真实去请求数据。
E2E 就是去测试 adaptor 了,因为上面单测除了 adaptor 没测。
我已 Express 、 Jest 为例。我的想法是直接用 Express 托管一系列接口。当然不是手动去启动一个服务,而是 Express 直接跑在 Jest 测试中。
首先写一个方法,起一个 Express 实例。
// __tests__/helpers/e2e-mock-server.ts
import cors from 'cors'
import express from 'express'
import { AddressInfo } from 'net'
type Express = ReturnType<typeof express>
export const createMockServer = (options: { port?: number } = {}) => {
const { port = 0 } = options
const app: Express = express()
app.use(express.json())
app.use(cors())
const server = app.listen(port)
return {
app,
port: (server.address() as AddressInfo).port,
server,
close() {
server.close()
},
}
}
port 建议为 0
,0 表示使用随机一个空闲的端口。因为固定端口在 Jest 并行测试中容易被占用。
测试用例也比较好写,只要按照传统前后端接口请求去写就可以了。如下:
import { allControllers, createClient, HTTPClient, RequestError } from '~/core'
import { IRequestAdapter } from '~/interfaces/instance'
import { createMockServer } from './e2e-mock-server'
export const testAdaptor = (adaptor: IRequestAdapter) => {
let client: HTTPClient
const { app, close, port } = createMockServer()
afterAll(() => {
close()
})
beforeAll(() => {
client = createClient(adaptor)('http://localhost:' + port)
client.injectControllers(allControllers)
})
test('get', async () => {
app.get('/posts/1', (req, res) => {
res.send({
id: '1',
})
})
const res = await client.post.getPost('1')
expect(res).toStrictEqual({
id: '1',
})
})
test('post', async () => {
app.post('/comments/1', (req, res) => {
const { body } = req
res.send({
...body,
})
})
const dto = {
text: 'hello',
author: 'test',
mail: '[email protected]',
}
const res = await client.comment.comment('1', dto)
expect(res).toStrictEqual(dto)
})
test('get with search query', async () => {
app.get('/search/post', (req, res) => {
if (req.query.keyword) {
return res.send({ result: 1 })
}
res.send(null)
})
const res = await client.search.search('post', 'keyword')
expect(res).toStrictEqual({ result: 1 })
})
test('rawResponse rawRequest should defined', async () => {
app.get('/search/post', (req, res) => {
if (req.query.keyword) {
return res.send({ result: 1 })
}
res.send(null)
})
const res = await client.search.search('post', 'keyword')
expect(res.$raw).toBeDefined()
expect(res.$raw.data).toBeDefined()
})
it('should error catch', async () => {
app.get('/error', (req, res) => {
res.status(500).send({
message: 'error message',
})
})
await expect(client.proxy.error.get()).rejects.toThrowError(RequestError)
})
}
// __test__/adaptors/axios.ts
import { umiAdaptor } from '~/adaptors/umi-request'
import { testAdaptor } from '../helpers/adaptor-test'
describe('test umi-request adaptor', () => {
testAdaptor(umiAdaptor)
})
上面封装了一个方法去测试 adaptor,有多次 adaptor 的话比较方便。
测试主要覆盖了,adaptor 接口是否正确,请求构造是否正确,返回数据是否正确。
写起来还是比较简单的,注意的是,测试跑完后不要忘了把 Express 销毁,即 server.close()
。
完整项目参考: