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

2 年前
1332
2

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

完整项目参考:

评论区加载中...