请求模块单元测试
# 请求模块单元测试
请求模块是 axios 最基础的模块,通过一个 axios 方法发送 Ajax 请求。
# jasmine-ajax
Jasmine (opens new window) 是一个 BDD(行为驱动开发)的测试框架,它有很多成熟的插件,比如我们要用到的 jasmine-ajax
(opens new window),它会为我们发出的 Ajax 请求根据规范定义一组假的响应,并跟踪我们发出的Ajax请求,可以让我们方便的为结果做断言。
其实 Jest 也可以去写插件,但并没有现成的 Ajax 相关的 Jest 插件,但是 Jest 测试中我们仍然可以使用 Jasmine 相关的插件,只需要做一些小小的配置即可。
当然,未来我也会考虑去编写一个 Ajax 相关的 Jest 插件,目前我们仍然使用 jasmine-ajax
去配合我们编写测试。
jasmine-ajax
依赖 jasmine-core
,因此首先我们要安装几个依赖包,jasmine-ajax
、jasmine-core
和 @types/jasmine-ajax
。
这个时候我们需要去修改 test/boot.ts
文件,因为每次跑具体测试代码之前会先运行该文件,我们可以在这里去初始化 jasmine-ajax
。
const JasmineCore = require('jasmine-core')
// @ts-ignore
global.getJasmineRequireObj = function() {
return JasmineCore
}
require('jasmine-ajax')
2
3
4
5
6
这里为了让 jasmine-ajax
插件运行成功,我们需要手动添加全局的 getJasmineRequireObj
方法,参考 issue (opens new window)。
接下来,我们就开始编写请求模块的单元测试。
# 测试代码编写
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')
})
})
})
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
我们要注意的一些点,在这里列出:
- beforeEach & afterEach
beforeEach (opens new window)表示每个测试用例运行前的钩子函数,在这里我们执行 jasmine.Ajax.install()
安装 jasmine.Ajax
。
afterEach (opens new window)表示每个测试用例运行后的钩子函数,在这里我们执行 jasmine.Ajax.uninstall()
卸载 jasmine.Ajax
。
getAjaxRequest
getAjaxRequest
是我们在 test/helper.ts
定义的一个辅助方法,通过 jasmine.Ajax.requests.mostRecent()
拿到最近一次请求的 request
对象,这个 request
对象是 jasmine-ajax
库伪造的 xhr
对象,它模拟了 xhr
对象上的方法,并且提供一些 api
让我们使用,比如 request.respondWith
方法返回一个响应。
- 异步测试
注意到我们这里大部分的测试用例不再是同步的代码了,几乎都是一些异步逻辑,Jest 非常好地支持异步测试代码 (opens new window)。通常有 2 种解决方案。
第一种是利用 done
参数,每个测试用例函数有一个 done
参数,一旦我们使用了该参数,只有当 done
函数执行的时候表示这个测试用例结束。
第二种是我们的测试函数返回一个 Promise 对象,一旦这个 Promise 对象 resolve
了,表示这个测试结束。
- expect.any(constructor)
它表示匹配任意由 constructor
创建的对象实例。
request.eventBus.trigger
由于 request.responseTimeout
方法内部依赖了 jasmine.clock
方法会导致运行失败,这里我直接用了 request.eventBus.trigger('timeout')
方法触发了 timeout
事件。因为这个方法不在接口定义中,所以需要加 // @ts-ignore
。
另外,我们在测试中发现 2 个 case 没有通过。
第一个是 should treat method value as lowercase string
,这个测试用例是我们发送请求的 method
需要转换成小写字符串,这么做的目的也是为了之后 flattenHeaders
能正常处理这些 method
,所以我们需要修改源码逻辑。
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()
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在合并配置后,我们需要把 config.method
转成小写字符串。
另一个是 should return JSON when rejecting
,这个测试用例是当我们发送请求失败后,也能把响应数据转换成 JSON 格式,所以也需要修改源码逻辑。
core/dispatchRequest.ts
:
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)
}
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
除了对正常情况的响应数据做转换,我们也需要对异常情况的响应数据做转换。
至此我们完成了 ts-axios
库对请求模块的测试,下一节课我们会从业务的角度来测试 headers
模块。