最近在写适配 Mx Space Server 的 JS SDK。因为想写一个正式一点的库,以后真正能派的上用场的,所以写的时候尽量严谨一点。所以单测和 E2E 也是非常重要。
架构设计
先说说我这个接口库是怎么封装了,然后再说怎么去测试。首先我采用的是适配器模式,也就是不依赖任何一个第三方请求库,你可以用 axios、ky、umi-request、fetch 任何一个库,只需要编写一个符合接口标准的适配器。这里以 axios 为例。
配适器接口如下,目前比较简单。
tsx
代码语言:javascript复制1export interface IRequestAdapter<T = any> {
2 default: T
3 get<P = unknown>(
4 url: string,
5 options?: RequestOptions,
6 ): RequestResponseType<P>
7
8 post<P = unknown>(
9 url: string,
10 options?: RequestOptions,
11 ): RequestResponseType<P>
12
13 patch<P = unknown>(
14 url: string,
15 options?: RequestOptions,
16 ): RequestResponseType<P>
17
18 delete<P = unknown>(
19 url: string,
20 options?: RequestOptions,
21 ): RequestResponseType<P>
22
23 put<P = unknown>(
24 url: string,
25 options?: RequestOptions,
26 ): RequestResponseType<P>
27}
COPY
实现 axios-adaptor 如下:
tsx
代码语言:javascript复制1import axios, { AxiosInstance } from 'axios'
2import { IRequestAdapter } from '~/interfaces/instance'
3const $http = axios.create({})
4
5// ignore axios `method` declare not assignable to `Method`
6export const axiosAdaptor: IRequestAdapter<AxiosInstance> = {
7 get default() {
8 return $http
9 },
10
11 get(url, options) {
12 // @ts-ignore
13 return $http.get(url, options)
14 },
15 post(url, options) {
16 const { data, ...config } = options || {}
17 // @ts-ignore
18 return $http.post(url, data, config)
19 },
20 put(url, options) {
21 const { data, ...config } = options || {}
22 // @ts-ignore
23 return $http.put(url, data, config)
24 },
25 delete(url, options) {
26 const { ...config } = options || {}
27 // @ts-ignore
28 return $http.delete(url, config)
29 },
30 patch(url, options) {
31 const { data, ...config } = options || {}
32 // @ts-ignore
33 return $http.patch(url, data, config)
34 },
35}
COPY
然后在构造 client 的时候要注入 adaptor。如下:
tsx
代码语言:javascript复制1const client = createClient(axiosAdaptor)(endpoint)
2client.post.post.getList(page, 10, { year }).then((data) => {
3 // do anything
4})
COPY
注入 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 这层。
flowchart TD id1([core: client]) --> id2([adaptor: axios]) -- use adaptor to fetch data --> id3([core: handle data]) --> id5([return data])
所以测试可以这样去写:
tsx
代码语言:javascript复制1 describe('client `get` method', () => {
2 afterEach(() => {
3 jest.resetAllMocks()
4 })
5 test('case 1', async () => {
6 jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
7 if (url === 'http://127.0.0.1:2323/a/a?foo=bar') {
8 return Promise.resolve({ data: { ok: 1 } })
9 }
10
11 return Promise.resolve({ data: null })
12 })
13
14 const client = generateClient()
15 const data = await client.proxy.a.a.get({ params: { foo: 'bar' } })
16
17 expect(data).toStrictEqual({ ok: 1 })
18 })
19
20 test('case 2', async () => {
21 jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
22 if (url === 'http://127.0.0.1:2323/a/a') {
23 return Promise.resolve({ data: { ok: 1 } })
24 }
25
26 return Promise.resolve({ data: null })
27 })
28
29 const client = generateClient()
30 const data = await client.proxy.a.a.get()
31
32 expect(data).toStrictEqual({ ok: 1 })
33
34 {
35 jest.spyOn(axiosAdaptor, 'get').mockImplementation((url, config) => {
36 if (url === 'http://127.0.0.1:2323/a/b') {
37 return Promise.resolve({ data: { ok: 1 } })
38 }
39
40 return Promise.resolve({ data: null })
41 })
42
43 const client = generateClient()
44 const data = await client.proxy.a.b.get()
45
46 expect(data).toStrictEqual({ ok: 1 })
47 }
48 })
49 })
COPY
如上,直接用 Jest spyOn 掉了 adaptor 的 get 方法,而要测试的则是 core 层有没有正确使用 adaptor 访问了正确的路径。所以在 mockImplementation 中,判断了是不是这个这个 url。
以上则是单测中的一环,client - adaptor - core 的测试。
然后说说单个接口怎么去写单测。我感觉这里其实没有什么必要去写。但是写了还是写一下,我也不知道有什么好的办法。还是使用 mock 的方法 mock 掉 adaptor 的请求返回。简单说说就是这样写了。
比如测试接口 /comments/:id
:
ts
代码语言:javascript复制1describe('test note client', () => {
2 const client = mockRequestInstance(CommentController)
3
4 test('get comment by id', async () => {
5 const mocked = mockResponse('/comments/11111', {
6 ref_type: 'Page',
7 state: 1,
8 children: [],
9 comments_index: 1,
10 id: '6188b80b6290547080c9e1f3',
11 author: 'yss',
12 text: '做的框架模板不错. (•౪• ) ',
13 url: 'https://gitee.com/kmyss/',
14 key: '#26',
15 ref: '5e0318319332d06503619337',
16 created: '2021-11-08T05:39:23.010Z',
17 avatar:
18 'https://sdn.geekzu.org/avatar/8675fa376c044b0d93a23374549c4248?d=retro',
19 })
20
21 const data = await client.comment.getById('11111')
22 expect(data).toEqual(mocked)
23 })
24}
COPY
这边主要就是测试这个方法中请求的路径有没有写对了,但是非常关键的是用例中的路径一定要写对,上面那个的话就是 /comments/11111
,mockResponse
是我封装的一个测试方法。具体参考:
@mx-space/api-client:__test__/helper
E2E test
E2E 是点对点测试,是需要去真实访问接口的,这也是最接近用户实际开发体验的测试,也就是说不 mock 掉 adaptor,也不在业务层用假数据的。当然假数据还是要用的,只是需要起一个额外的服务器去挂数据,以便真实去请求数据。
E2E 就是去测试 adaptor 了,因为上面单测除了 adaptor 没测。
我已 Express 、 Jest 为例。我的想法是直接用 Express 托管一系列接口。当然不是手动去启动一个服务,而是 Express 直接跑在 Jest 测试中。
首先写一个方法,起一个 Express 实例。
ts
代码语言:javascript复制1// __tests__/helpers/e2e-mock-server.ts
2import cors from 'cors'
3import express from 'express'
4import { AddressInfo } from 'net'
5type Express = ReturnType<typeof express>
6export const createMockServer = (options: { port?: number } = {}) => {
7 const { port = 0 } = options
8
9 const app: Express = express()
10 app.use(express.json())
11 app.use(cors())
12 const server = app.listen(port)
13
14 return {
15 app,
16 port: (server.address() as AddressInfo).port,
17 server,
18 close() {
19 server.close()
20 },
21 }
22}
COPY
port 建议为 0
,0 表示使用随机一个空闲的端口。因为固定端口在 Jest 并行测试中容易被占用。
测试用例也比较好写,只要按照传统前后端接口请求去写就可以了。如下:
ts
代码语言:javascript复制1import { allControllers, createClient, HTTPClient, RequestError } from '~/core'
2import { IRequestAdapter } from '~/interfaces/instance'
3import { createMockServer } from './e2e-mock-server'
4
5export const testAdaptor = (adaptor: IRequestAdapter) => {
6 let client: HTTPClient
7 const { app, close, port } = createMockServer()
8
9 afterAll(() => {
10 close()
11 })
12 beforeAll(() => {
13 client = createClient(adaptor)('http://localhost:' port)
14 client.injectControllers(allControllers)
15 })
16 test('get', async () => {
17 app.get('/posts/1', (req, res) => {
18 res.send({
19 id: '1',
20 })
21 })
22 const res = await client.post.getPost('1')
23
24 expect(res).toStrictEqual({
25 id: '1',
26 })
27 })
28
29 test('post', async () => {
30 app.post('/comments/1', (req, res) => {
31 const { body } = req
32
33 res.send({
34 ...body,
35 })
36 })
37 const dto = {
38 text: 'hello',
39 author: 'test',
40 mail: '1@ee.com',
41 }
42 const res = await client.comment.comment('1', dto)
43
44 expect(res).toStrictEqual(dto)
45 })
46
47 test('get with search query', async () => {
48 app.get('/search/post', (req, res) => {
49 if (req.query.keyword) {
50 return res.send({ result: 1 })
51 }
52 res.send(null)
53 })
54
55 const res = await client.search.search('post', 'keyword')
56 expect(res).toStrictEqual({ result: 1 })
57 })
58
59 test('rawResponse rawRequest should defined', async () => {
60 app.get('/search/post', (req, res) => {
61 if (req.query.keyword) {
62 return res.send({ result: 1 })
63 }
64 res.send(null)
65 })
66
67 const res = await client.search.search('post', 'keyword')
68 expect(res.$raw).toBeDefined()
69 expect(res.$raw.data).toBeDefined()
70 })
71
72 it('should error catch', async () => {
73 app.get('/error', (req, res) => {
74 res.status(500).send({
75 message: 'error message',
76 })
77 })
78 await expect(client.proxy.error.get()).rejects.toThrowError(RequestError)
79 })
80}
81
82// __test__/adaptors/axios.ts
83
84import { umiAdaptor } from '~/adaptors/umi-request'
85import { testAdaptor } from '../helpers/adaptor-test'
86describe('test umi-request adaptor', () => {
87 testAdaptor(umiAdaptor)
88})
COPY
上面封装了一个方法去测试 adaptor,有多次 adaptor 的话比较方便。
测试主要覆盖了,adaptor 接口是否正确,请求构造是否正确,返回数据是否正确。
写起来还是比较简单的,注意的是,测试跑完后不要忘了把 Express 销毁,即 server.close()
。
完整项目参考:
mx-space/api-client