请求模块单元测试

请求模块是 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-ajaxjasmine-core@types/jasmine-ajax

这个时候我们需要去修改 test/boot.ts 文件,因为每次跑具体测试代码之前会先运行该文件,我们可以在这里去初始化 jasmine-ajax

  1. const JasmineCore = require('jasmine-core')
  2. // @ts-ignore
  3. global.getJasmineRequireObj = function() {
  4. return JasmineCore
  5. }
  6. require('jasmine-ajax')

这里为了让 jasmine-ajax 插件运行成功,我们需要手动添加全局的 getJasmineRequireObj 方法,参考 issue

接下来,我们就开始编写请求模块的单元测试。

测试代码编写

test/requests.spec.ts

  1. import axios, { AxiosResponse, AxiosError } from '../src/index'
  2. import { getAjaxRequest } from './helper'
  3. describe('requests', () => {
  4. beforeEach(() => {
  5. jasmine.Ajax.install()
  6. })
  7. afterEach(() => {
  8. jasmine.Ajax.uninstall()
  9. })
  10. test('should treat single string arg as url', () => {
  11. axios('/foo')
  12. return getAjaxRequest().then(request => {
  13. expect(request.url).toBe('/foo')
  14. expect(request.method).toBe('GET')
  15. })
  16. })
  17. test('should treat method value as lowercase string', done => {
  18. axios({
  19. url: '/foo',
  20. method: 'POST'
  21. }).then(response => {
  22. expect(response.config.method).toBe('post')
  23. done()
  24. })
  25. getAjaxRequest().then(request => {
  26. request.respondWith({
  27. status: 200
  28. })
  29. })
  30. })
  31. test('should reject on network errors', done => {
  32. const resolveSpy = jest.fn((res: AxiosResponse) => {
  33. return res
  34. })
  35. const rejectSpy = jest.fn((e: AxiosError) => {
  36. return e
  37. })
  38. jasmine.Ajax.uninstall()
  39. axios('/foo')
  40. .then(resolveSpy)
  41. .catch(rejectSpy)
  42. .then(next)
  43. function next(reason: AxiosResponse | AxiosError) {
  44. expect(resolveSpy).not.toHaveBeenCalled()
  45. expect(rejectSpy).toHaveBeenCalled()
  46. expect(reason instanceof Error).toBeTruthy()
  47. expect((reason as AxiosError).message).toBe('Network Error')
  48. expect(reason.request).toEqual(expect.any(XMLHttpRequest))
  49. jasmine.Ajax.install()
  50. done()
  51. }
  52. })
  53. test('should reject when request timeout', done => {
  54. let err: AxiosError
  55. axios('/foo', {
  56. timeout: 2000,
  57. method: 'post'
  58. }).catch(error => {
  59. err = error
  60. })
  61. getAjaxRequest().then(request => {
  62. // @ts-ignore
  63. request.eventBus.trigger('timeout')
  64. setTimeout(() => {
  65. expect(err instanceof Error).toBeTruthy()
  66. expect(err.message).toBe('Timeout of 2000 ms exceeded')
  67. done()
  68. }, 100)
  69. })
  70. })
  71. test('should reject when validateStatus returns false', done => {
  72. const resolveSpy = jest.fn((res: AxiosResponse) => {
  73. return res
  74. })
  75. const rejectSpy = jest.fn((e: AxiosError) => {
  76. return e
  77. })
  78. axios('/foo', {
  79. validateStatus(status) {
  80. return status !== 500
  81. }
  82. })
  83. .then(resolveSpy)
  84. .catch(rejectSpy)
  85. .then(next)
  86. getAjaxRequest().then(request => {
  87. request.respondWith({
  88. status: 500
  89. })
  90. })
  91. function next(reason: AxiosError | AxiosResponse) {
  92. expect(resolveSpy).not.toHaveBeenCalled()
  93. expect(rejectSpy).toHaveBeenCalled()
  94. expect(reason instanceof Error).toBeTruthy()
  95. expect((reason as AxiosError).message).toBe('Request failed with status code 500')
  96. expect((reason as AxiosError).response!.status).toBe(500)
  97. done()
  98. }
  99. })
  100. test('should resolve when validateStatus returns true', done => {
  101. const resolveSpy = jest.fn((res: AxiosResponse) => {
  102. return res
  103. })
  104. const rejectSpy = jest.fn((e: AxiosError) => {
  105. return e
  106. })
  107. axios('/foo', {
  108. validateStatus(status) {
  109. return status === 500
  110. }
  111. })
  112. .then(resolveSpy)
  113. .catch(rejectSpy)
  114. .then(next)
  115. getAjaxRequest().then(request => {
  116. request.respondWith({
  117. status: 500
  118. })
  119. })
  120. function next(res: AxiosResponse | AxiosError) {
  121. expect(resolveSpy).toHaveBeenCalled()
  122. expect(rejectSpy).not.toHaveBeenCalled()
  123. expect(res.config.url).toBe('/foo')
  124. done()
  125. }
  126. })
  127. test('should return JSON when resolved', done => {
  128. let response: AxiosResponse
  129. axios('/api/account/signup', {
  130. auth: {
  131. username: '',
  132. password: ''
  133. },
  134. method: 'post',
  135. headers: {
  136. Accept: 'application/json'
  137. }
  138. }).then(res => {
  139. response = res
  140. })
  141. getAjaxRequest().then(request => {
  142. request.respondWith({
  143. status: 200,
  144. statusText: 'OK',
  145. responseText: '{"a": 1}'
  146. })
  147. setTimeout(() => {
  148. expect(response.data).toEqual({ a: 1 })
  149. done()
  150. }, 100)
  151. })
  152. })
  153. test('should return JSON when rejecting', done => {
  154. let response: AxiosResponse
  155. axios('/api/account/signup', {
  156. auth: {
  157. username: '',
  158. password: ''
  159. },
  160. method: 'post',
  161. headers: {
  162. Accept: 'application/json'
  163. }
  164. }).catch(error => {
  165. response = error.response
  166. })
  167. getAjaxRequest().then(request => {
  168. request.respondWith({
  169. status: 400,
  170. statusText: 'Bad Request',
  171. responseText: '{"error": "BAD USERNAME", "code": 1}'
  172. })
  173. setTimeout(() => {
  174. expect(typeof response.data).toBe('object')
  175. expect(response.data.error).toBe('BAD USERNAME')
  176. expect(response.data.code).toBe(1)
  177. done()
  178. }, 100)
  179. })
  180. })
  181. test('should supply correct response', done => {
  182. let response: AxiosResponse
  183. axios.post('/foo').then(res => {
  184. response = res
  185. })
  186. getAjaxRequest().then(request => {
  187. request.respondWith({
  188. status: 200,
  189. statusText: 'OK',
  190. responseText: '{"foo": "bar"}',
  191. responseHeaders: {
  192. 'Content-Type': 'application/json'
  193. }
  194. })
  195. setTimeout(() => {
  196. expect(response.data.foo).toBe('bar')
  197. expect(response.status).toBe(200)
  198. expect(response.statusText).toBe('OK')
  199. expect(response.headers['content-type']).toBe('application/json')
  200. done()
  201. }, 100)
  202. })
  203. })
  204. test('should allow overriding Content-Type header case-insensitive', () => {
  205. let response: AxiosResponse
  206. axios
  207. .post(
  208. '/foo',
  209. { prop: 'value' },
  210. {
  211. headers: {
  212. 'content-type': 'application/json'
  213. }
  214. }
  215. )
  216. .then(res => {
  217. response = res
  218. })
  219. return getAjaxRequest().then(request => {
  220. expect(request.requestHeaders['Content-Type']).toBe('application/json')
  221. })
  222. })
  223. })

我们要注意的一些点,在这里列出:

  • beforeEach & afterEach

beforeEach表示每个测试用例运行前的钩子函数,在这里我们执行 jasmine.Ajax.install() 安装 jasmine.Ajax

afterEach表示每个测试用例运行后的钩子函数,在这里我们执行 jasmine.Ajax.uninstall() 卸载 jasmine.Ajax

  • getAjaxRequest

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 了,表示这个测试结束。

  • 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

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

在合并配置后,我们需要把 config.method 转成小写字符串。

另一个是 should return JSON when rejecting,这个测试用例是当我们发送请求失败后,也能把响应数据转换成 JSON 格式,所以也需要修改源码逻辑。

core/dispatchRequest.ts

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

除了对正常情况的响应数据做转换,我们也需要对异常情况的响应数据做转换。

至此我们完成了 ts-axios 库对请求模块的测试,下一节课我们会从业务的角度来测试 headers 模块。