目录

🌖 请求模块单元测试

为了测试请求模块相关的业务逻辑,需要用到工具 jasmine

jasmine-ajax:

  • Jasmine (opens new window) (opens new window) 是一个 BDD (行为驱动开发) 的测试框架,它有很多成熟的插件,比如我们要用到的 jasmine-ajax (opens new window),它会为我们发出的 Ajax 请求根据规范定义一组假的响应,并跟踪我们发出的 Ajax 请求,可以让我们方便的为结果做断言。
  • jasmine-ajax 依赖 jasmine-core ,因此首先我们要安装几个依赖包, jasmine-ajaxjasmine-core@types/jasmine-ajax

要使用 jasmine-ajax 插件能全局运行,需要手动台那集全局的方法(参考 ReferenceError: getJasmineRequireObj is not defined · Issue #178 · jasmine/jasmine-ajax (github.com) (opens new window)):

test/boot.ts

const JasmineCore = require('jasmine-core')
// @ts-ignore
global.getJasmineRequireObj = function() {
  return JasmineCore
}

require('jasmine')
1
2
3
4
5
6
7

# 测试代码编写

创建文件 test/helper.tsgetAjaxRequest

require('jasmine-ajax')

export function getAjaxRequest(): Promise<JasmineAjaxRequest> {
  return new Promise(function(resolve) {
    setTimeout(() => {
      return resolve(jasmine.Ajax.requests.mostRecent())
    }, 0)
  })
}

1
2
3
4
5
6
7
8
9
10

一个辅助方法,通过 jasmine.Ajax.requests.mostRecent() 拿到最近一次请求的 request 对象,这个 request 对象是 jasmine-ajax 库伪造的 xhr 对象,它模拟了 xhr 对象上的方法,并且提供一些 api 让我们使用,比如 request.respondWith 方法返回一个响应。

🤯 踩坑预警:前前后后调试 jest 的配置 setupFilesAfterEnv ;最后把这个字段换成了 setUpFiles ,仍然设置为 <rootDir>/test/boot.ts 。也不知道这个配置是不是被弃用了,反正就莫名的换完之后就能成功使用 jasmine 了。

创建文件 test/requests.spec.ts

import axios, { AxiosResponse, AxiosError } from '../src/index'
import { getAjaxRequest } from './helper'

describe('requests', () => {
  beforeEach(() => {
    jasmine.Ajax.install()
  })

  afterEach(() => {
    jasmine.Ajax.uninstall()
  })

  test('should treat single string arg as url', () => {
    axios('/foo')

    return getAjaxRequest().then(request => {
      expect(request.url).toBe('/foo')
      expect(request.method).toBe('GET')
    })
  })

  test('should treat method value as lowercase string', done => {
    axios({
      url: '/foo',
      method: 'POST'
    }).then(response => {
      expect(response.config.method).toBe('post')
      done()
    })

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 200
      })
    })
  })

  test('should reject on network errors', done => {
    const resolveSpy = jest.fn((res: AxiosResponse) => {
      return res
    })

    const rejectSpy = jest.fn((e: AxiosError) => {
      return e
    })

    jasmine.Ajax.uninstall()

    axios('/foo')
      .then(resolveSpy)
      .catch(rejectSpy)
      .then(next)

    function next(reason: AxiosResponse | AxiosError) {
      expect(resolveSpy).not.toHaveBeenCalled()
      expect(rejectSpy).toHaveBeenCalled()
      expect(reason instanceof Error).toBeTruthy()
      expect((reason as AxiosError).message).toBe('Network Error')
      expect(reason.request).toEqual(expect.any(XMLHttpRequest))

      jasmine.Ajax.install()

      done()
    }
  })

  test('should reject when request timeout', done => {
    let err: AxiosError

    axios('/foo', {
      timeout: 2000,
      method: 'post'
    }).catch(error => {
      err = error
    })

    getAjaxRequest().then(request => {
      // @ts-ignore
      request.eventBus.trigger('timeout')

      setTimeout(() => {
        expect(err instanceof Error).toBeTruthy()
        expect(err.message).toBe('Timeout of 2000 ms exceeded')
        done()
      }, 100)
    })
  })

  test('should reject when validateStatus returns false', done => {
    const resolveSpy = jest.fn((res: AxiosResponse) => {
      return res
    })

    const rejectSpy = jest.fn((e: AxiosError) => {
      return e
    })

    axios('/foo', {
      validateStatus(status) {
        return status !== 500
      }
    })
      .then(resolveSpy)
      .catch(rejectSpy)
      .then(next)

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 500
      })
    })

    function next(reason: AxiosError | AxiosResponse) {
      expect(resolveSpy).not.toHaveBeenCalled()
      expect(rejectSpy).toHaveBeenCalled()
      expect(reason instanceof Error).toBeTruthy()
      expect((reason as AxiosError).message).toBe('Request failed with status code 500')
      expect((reason as AxiosError).response!.status).toBe(500)

      done()
    }
  })

  test('should resolve when validateStatus returns true', done => {
    const resolveSpy = jest.fn((res: AxiosResponse) => {
      return res
    })

    const rejectSpy = jest.fn((e: AxiosError) => {
      return e
    })

    axios('/foo', {
      validateStatus(status) {
        return status === 500
      }
    })
      .then(resolveSpy)
      .catch(rejectSpy)
      .then(next)

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 500
      })
    })

    function next(res: AxiosResponse | AxiosError) {
      expect(resolveSpy).toHaveBeenCalled()
      expect(rejectSpy).not.toHaveBeenCalled()
      expect(res.config.url).toBe('/foo')

      done()
    }
  })

  test('should return JSON when resolved', done => {
    let response: AxiosResponse

    axios('/api/account/signup', {
      auth: {
        username: '',
        password: ''
      },
      method: 'post',
      headers: {
        Accept: 'application/json'
      }
    }).then(res => {
      response = res
    })

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 200,
        statusText: 'OK',
        responseText: '{"a": 1}'
      })

      setTimeout(() => {
        expect(response.data).toEqual({ a: 1 })
        done()
      }, 100)
    })
  })

  test('should return JSON when rejecting', done => {
    let response: AxiosResponse

    axios('/api/account/signup', {
      auth: {
        username: '',
        password: ''
      },
      method: 'post',
      headers: {
        Accept: 'application/json'
      }
    }).catch(error => {
      response = error.response
    })

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 400,
        statusText: 'Bad Request',
        responseText: '{"error": "BAD USERNAME", "code": 1}'
      })

      setTimeout(() => {
        expect(typeof response.data).toBe('object')
        expect(response.data.error).toBe('BAD USERNAME')
        expect(response.data.code).toBe(1)
        done()
      }, 100)
    })
  })

  test('should supply correct response', done => {
    let response: AxiosResponse

    axios.post('/foo').then(res => {
      response = res
    })

    getAjaxRequest().then(request => {
      request.respondWith({
        status: 200,
        statusText: 'OK',
        responseText: '{"foo": "bar"}',
        responseHeaders: {
          'Content-Type': 'application/json'
        }
      })

      setTimeout(() => {
        expect(response.data.foo).toBe('bar')
        expect(response.status).toBe(200)
        expect(response.statusText).toBe('OK')
        expect(response.headers['content-type']).toBe('application/json')
        done()
      }, 100)
    })
  })

  test('should allow overriding Content-Type header case-insensitive', () => {
    let response: AxiosResponse

    axios
      .post(
        '/foo',
        { prop: 'value' },
        {
          headers: {
            'content-type': 'application/json'
          }
        }
      )
      .then(res => {
        response = res
      })

    return getAjaxRequest().then(request => {
      expect(request.requestHeaders['Content-Type']).toBe('application/json')
    })
  })
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268

测试相关的配置:

  • beforeEach() & afterEach() :表示每个测试用例前后运行的狗子函数。

  • 异步测试。在这个测试中,几乎都是用的是异步的测试代码。通常 Jest 有两种解决方案:

    • 第一种是利用 done 参数,每个测试用例函数有一个 done 参数,一旦使用这个参数,只有当 done 函数执行的时候表示这个测试用例结束。
    • 第二种是测试函数返回一个 Promise 对象,一旦这个 Promise 对象 resolve 了,表示这个测试结束。
  • expect.any(constructor)constructor 是要匹配的对象实例的构造器。

  • request.eventBus.trigger :由于 request.responseTimeout 方法内部依赖了 jasmine.clock 方法会导致运行失败。这里直接使用 request.eventBus.trigger('timeout') 方法触发 timeout 事件。这个方法不在接口中定义,所以需要忽略语法警告 // @ts-ignore

测试中发现的问题:

  • should treat method value as lowercase string 断言有误:测试用例中,发送请求的 method 需要转换成小写字符串,这样做的目的是为了之后的 flattenHeaders 能正常处理 method ,所以需要增加一个添加转换为小写字符串的逻辑:

    src/core/Axios.ts ):

    request(url: any, config?: any): AxiosPromise {
      if (typeof url === 'string') {
        if (!config) {
          config = {}
        }
        config.url = url
      } else {
        config = url
      }
    
      config = mergeConfig(this.defaults, config)
      config.method = config.method.toLowerCase()
    
      // ...
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    合并配置后,将 config.method 转换为小写。

  • should return JSON when rejecting 断言有误:这个测试用例是要测试发送请求失败之后,也能把相应数据转换为 JSON 格式。而原来的代码并没有这个操作,所以需要修改源码。

    export default function dispatchRequest(config: AxiosRequestConfig): AxiosPromise {
      throwIfCancellationRequested(config)
      processConfig(config)
      return xhr(config).then(
        res => {
          return transformResponseData(res)
        },
        e => {
          if (e && e.response) {
            e.response = transformResponseData(e.response)
          }
          return Promise.reject(e)
        }
      )
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    除了正常情况的响应数据需要作转换,错误情况的相应数据也需要作转换。

📢 上次更新: 2022/09/02, 10:18:16