编写接口请求库单元测试与 E2E 测试的思考

2021-12-28 10:37:02 浏览数 (1)

最近在写适配 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/11111mockResponse是我封装的一个测试方法。具体参考:

@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

0 人点赞