请求模块单元测试
请求模块是 axios 最基础的模块,通过一个 axios 方法发送 Ajax 请求。
jasmine-ajax
Jasmine 是一个 BDD(行为驱动开发)的测试框架,它有很多成熟的插件,比如我们要用到的 jasmine-ajax,它会为我们发出的 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。
1 2 3 4 5 6
| const JasmineCore = require('jasmine-core')
global.getJasmineRequireObj = function() { return JasmineCore } require('jasmine-ajax')
|
这里为了让 jasmine-ajax 插件运行成功,我们需要手动添加全局的 getJasmineRequireObj 方法,参考 issue。
接下来,我们就开始编写请求模块的单元测试。
测试代码编写
test/requests.spec.ts:
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
| 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 => { 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') }) }) })
|
我们要注意的一些点,在这里列出:
beforeEach表示每个测试用例运行前的钩子函数,在这里我们执行 jasmine.Ajax.install() 安装 jasmine.Ajax。
afterEach表示每个测试用例运行后的钩子函数,在这里我们执行 jasmine.Ajax.uninstall() 卸载 jasmine.Ajax。
getAjaxRequest 是我们在 test/helper.ts 定义的一个辅助方法,通过 jasmine.Ajax.requests.mostRecent() 拿到最近一次请求的 request 对象,这个 request 对象是 jasmine-ajax 库伪造的 xhr 对象,它模拟了 xhr 对象上的方法,并且提供一些 api 让我们使用,比如 request.respondWith 方法返回一个响应。
注意到我们这里大部分的测试用例不再是同步的代码了,几乎都是一些异步逻辑,Jest 非常好地支持异步测试代码。通常有 2 种解决方案。
第一种是利用 done 参数,每个测试用例函数有一个 done 参数,一旦我们使用了该参数,只有当 done 函数执行的时候表示这个测试用例结束。
第二种是我们的测试函数返回一个 Promise 对象,一旦这个 Promise 对象 resolve 了,表示这个测试结束。
它表示匹配任意由 constructor 创建的对象实例。
由于 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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()
}
|
在合并配置后,我们需要把 config.method 转成小写字符串。
另一个是 should return JSON when rejecting,这个测试用例是当我们发送请求失败后,也能把响应数据转换成 JSON 格式,所以也需要修改源码逻辑。
core/dispatchRequest.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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) } ) }
|
除了对正常情况的响应数据做转换,我们也需要对异常情况的响应数据做转换。
至此我们完成了 ts-axios 库对请求模块的测试,下一节课我们会从业务的角度来测试 headers 模块。
来源:本文导入自 xugaoyi/vuepress-theme-vdoing 的 docs/《TypeScript 从零实现 axios》/11.ts-axios 单元测试/04.请求模块单元测试.md。
原作者:HuangYi。许可证:MIT。