辅助模块单元测试

准备工作

通常我们会优先为一个库的辅助方法编写测试,我们会优先为 ts-axios 库的 helpers 目录下的模块编写测试。我们在 test 目录下创建一个 helpers 目录,创建一个 boot.ts 空文件,这个是因为我们上节课给 Jest 配置了 setupFilesAfterEnv 指向了这个文件,后面的章节我们会编写这个文件。

然后我们可以在控制台运行 npm test,它实际上是执行了 jest --coverage 来跑单元测试,我们会发现它会报错,没有匹配的测试文件,那是因为我们还没有在 test 目录下编写任何一个 .spec.ts 结尾的测试文件。接下来我们就来为这些辅助模块编写相应的测试。

util 模块测试

test/helpers/util.spec.ts

  1. import {
  2. isDate,
  3. isPlainObject,
  4. isFormData,
  5. isURLSearchParams,
  6. extend,
  7. deepMerge
  8. } from '../../src/helpers/util'
  9. describe('helpers:util', () => {
  10. describe('isXX', () => {
  11. test('should validate Date', () => {
  12. expect(isDate(new Date())).toBeTruthy()
  13. expect(isDate(Date.now())).toBeFalsy()
  14. })
  15. test('should validate PlainObject', () => {
  16. expect(isPlainObject({})).toBeTruthy()
  17. expect(isPlainObject(new Date())).toBeFalsy()
  18. })
  19. test('should validate FormData', () => {
  20. expect(isFormData(new FormData())).toBeTruthy()
  21. expect(isFormData({})).toBeFalsy()
  22. })
  23. test('should validate URLSearchParams', () => {
  24. expect(isURLSearchParams(new URLSearchParams())).toBeTruthy()
  25. expect(isURLSearchParams('foo=1&bar=2')).toBeFalsy()
  26. })
  27. })
  28. describe('extend', () => {
  29. test('should be mutable', () => {
  30. const a = Object.create(null)
  31. const b = { foo: 123 }
  32. extend(a, b)
  33. expect(a.foo).toBe(123)
  34. })
  35. test('should extend properties', function() {
  36. const a = { foo: 123, bar: 456 }
  37. const b = { bar: 789 }
  38. const c = extend(a, b)
  39. expect(c.foo).toBe(123)
  40. expect(c.bar).toBe(789)
  41. })
  42. })
  43. describe('deepMerge', () => {
  44. test('should be immutable', () => {
  45. const a = Object.create(null)
  46. const b: any = { foo: 123 }
  47. const c: any = { bar: 456 }
  48. deepMerge(a, b, c)
  49. expect(typeof a.foo).toBe('undefined')
  50. expect(typeof a.bar).toBe('undefined')
  51. expect(typeof b.bar).toBe('undefined')
  52. expect(typeof c.foo).toBe('undefined')
  53. })
  54. test('should deepMerge properties', () => {
  55. const a = { foo: 123 }
  56. const b = { bar: 456 }
  57. const c = { foo: 789 }
  58. const d = deepMerge(a, b, c)
  59. expect(d.foo).toBe(789)
  60. expect(d.bar).toBe(456)
  61. })
  62. test('should deepMerge recursively', function() {
  63. const a = { foo: { bar: 123 } }
  64. const b = { foo: { baz: 456 }, bar: { qux: 789 } }
  65. const c = deepMerge(a, b)
  66. expect(c).toEqual({
  67. foo: {
  68. bar: 123,
  69. baz: 456
  70. },
  71. bar: {
  72. qux: 789
  73. }
  74. })
  75. })
  76. test('should remove all references from nested objects', () => {
  77. const a = { foo: { bar: 123 } }
  78. const b = {}
  79. const c = deepMerge(a, b)
  80. expect(c).toEqual({
  81. foo: {
  82. bar: 123
  83. }
  84. })
  85. expect(c.foo).not.toBe(a.foo)
  86. })
  87. test('should handle null and undefined arguments', () => {
  88. expect(deepMerge(undefined, undefined)).toEqual({})
  89. expect(deepMerge(undefined, { foo: 123 })).toEqual({ foo: 123 })
  90. expect(deepMerge({ foo: 123 }, undefined)).toEqual({ foo: 123 })
  91. expect(deepMerge(null, null)).toEqual({})
  92. expect(deepMerge(null, { foo: 123 })).toEqual({ foo: 123 })
  93. expect(deepMerge({ foo: 123 }, null)).toEqual({ foo: 123 })
  94. })
  95. })
  96. })

其中 describe 方法用来定义一组测试,它可以支持嵌套,test 函数是用来定义单个测试用例,它是测试的最小单元。expect 是断言函数,所谓”断言”,就是判断代码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误。

测试文件编写好后,我们可以去控制台运行一次 npm test,看一下测试结果,我们可以看跑了几个测试文件,测试是否通过,测试覆盖率等。

cookie 模块测试

test/helpers/cookie.spec.ts

  1. import cookie from '../../src/helpers/cookie'
  2. describe('helpers:cookie', () => {
  3. test('should read cookies', () => {
  4. document.cookie = 'foo=baz'
  5. expect(cookie.read('foo')).toBe('baz')
  6. })
  7. test('should return null if cookie name is not exist', () => {
  8. document.cookie = 'foo=baz'
  9. expect(cookie.read('bar')).toBeNull()
  10. })
  11. })

这里我们可以通过 document.cookie 去设置 cookie,就像在浏览器里一样操作。

data 模块测试

test/helpers/data.spec.ts

  1. import { transformRequest, transformResponse } from '../../src/helpers/data'
  2. describe('helpers:data', () => {
  3. describe('transformRequest', () => {
  4. test('should transform request data to string if data is a PlainObject', () => {
  5. const a = { a: 1 }
  6. expect(transformRequest(a)).toBe('{"a":1}')
  7. })
  8. test('should do nothing if data is not a PlainObject', () => {
  9. const a = new URLSearchParams('a=b')
  10. expect(transformRequest(a)).toBe(a)
  11. })
  12. })
  13. describe('transformResponse', () => {
  14. test('should transform response data to Object if data is a JSON string', () => {
  15. const a = '{"a": 2}'
  16. expect(transformResponse(a)).toEqual({ a: 2 })
  17. })
  18. test('should do nothing if data is a string but not a JSON string', () => {
  19. const a = '{a: 2}'
  20. expect(transformResponse(a)).toBe('{a: 2}')
  21. })
  22. test('should do nothing if data is not a string', () => {
  23. const a = { a: 2 }
  24. expect(transformResponse(a)).toBe(a)
  25. })
  26. })
  27. })

error 模块测试

test/helpers/error.spec.ts

  1. import { createError } from '../../src/helpers/error'
  2. import { AxiosRequestConfig, AxiosResponse } from '../../src/types'
  3. describe('helpers::error', function() {
  4. test('should create an Error with message, config, code, request, response and isAxiosError', () => {
  5. const request = new XMLHttpRequest()
  6. const config: AxiosRequestConfig = { method: 'post' }
  7. const response: AxiosResponse = {
  8. status: 200,
  9. statusText: 'OK',
  10. headers: null,
  11. request,
  12. config,
  13. data: { foo: 'bar' }
  14. }
  15. const error = createError('Boom!', config, 'SOMETHING', request, response)
  16. expect(error instanceof Error).toBeTruthy()
  17. expect(error.message).toBe('Boom!')
  18. expect(error.config).toBe(config)
  19. expect(error.code).toBe('SOMETHING')
  20. expect(error.request).toBe(request)
  21. expect(error.response).toBe(response)
  22. expect(error.isAxiosError).toBeTruthy()
  23. })
  24. })

该模块跑完我们会发现,分支覆盖率是在 50%,因为第十七行代码

  1. super(message)

这个是 super 继承对测试覆盖率支持的坑,目前没有好的解决方案,可以先忽略。

headers 模块测试

test/helpers/headers.spec.ts

  1. import { parseHeaders, processHeaders, flattenHeaders } from '../../src/helpers/headers'
  2. describe('helpers:header', () => {
  3. describe('parseHeaders', () => {
  4. test('should parse headers', () => {
  5. const parsed = parseHeaders(
  6. 'Content-Type: application/json\r\n' +
  7. 'Connection: keep-alive\r\n' +
  8. 'Transfer-Encoding: chunked\r\n' +
  9. 'Date: Tue, 21 May 2019 09:23:44 GMT\r\n' +
  10. ':aa\r\n' +
  11. 'key:'
  12. )
  13. expect(parsed['content-type']).toBe('application/json')
  14. expect(parsed['connection']).toBe('keep-alive')
  15. expect(parsed['transfer-encoding']).toBe('chunked')
  16. expect(parsed['date']).toBe('Tue, 21 May 2019 09:23:44 GMT')
  17. expect(parsed['key']).toBe('')
  18. })
  19. test('should return empty object if headers is empty string', () => {
  20. expect(parseHeaders('')).toEqual({})
  21. })
  22. })
  23. describe('processHeaders', () => {
  24. test('should normalize Content-Type header name', () => {
  25. const headers: any = {
  26. 'conTenT-Type': 'foo/bar',
  27. 'Content-length': 1024
  28. }
  29. processHeaders(headers, {})
  30. expect(headers['Content-Type']).toBe('foo/bar')
  31. expect(headers['conTenT-Type']).toBeUndefined()
  32. expect(headers['Content-length']).toBe(1024)
  33. })
  34. test('should set Content-Type if not set and data is PlainObject', () => {
  35. const headers: any = {}
  36. processHeaders(headers, { a: 1 })
  37. expect(headers['Content-Type']).toBe('application/json;charset=utf-8')
  38. })
  39. test('should set not Content-Type if not set and data is not PlainObject', () => {
  40. const headers: any = {}
  41. processHeaders(headers, new URLSearchParams('a=b'))
  42. expect(headers['Content-Type']).toBeUndefined()
  43. })
  44. test('should do nothing if headers is undefined or null', () => {
  45. expect(processHeaders(undefined, {})).toBeUndefined()
  46. expect(processHeaders(null, {})).toBeNull()
  47. })
  48. })
  49. describe('flattenHeaders', () => {
  50. test('should flatten the headers and include common headers', () => {
  51. const headers = {
  52. Accept: 'application/json',
  53. common: {
  54. 'X-COMMON-HEADER': 'commonHeaderValue'
  55. },
  56. get: {
  57. 'X-GET-HEADER': 'getHeaderValue'
  58. },
  59. post: {
  60. 'X-POST-HEADER': 'postHeaderValue'
  61. }
  62. }
  63. expect(flattenHeaders(headers, 'get')).toEqual({
  64. Accept: 'application/json',
  65. 'X-COMMON-HEADER': 'commonHeaderValue',
  66. 'X-GET-HEADER': 'getHeaderValue'
  67. })
  68. })
  69. test('should flatten the headers without common headers', () => {
  70. const headers = {
  71. Accept: 'application/json',
  72. get: {
  73. 'X-GET-HEADER': 'getHeaderValue'
  74. }
  75. }
  76. expect(flattenHeaders(headers, 'patch')).toEqual({
  77. Accept: 'application/json'
  78. })
  79. })
  80. test('should do nothing if headers is undefined or null', () => {
  81. expect(flattenHeaders(undefined, 'get')).toBeUndefined()
  82. expect(flattenHeaders(null, 'post')).toBeNull()
  83. })
  84. })
  85. })

运行后,我们会发现 parseHeaders 测试组的 should parse headers 测试没通过,expect(parsed['date']).toBe('Tue, 21 May 2019 09:23:44 GMT') 我们期望解析后的 date 字段是 Tue, 21 May 2019 09:23:44 GMT,而实际的值是 Tue, 21 May 2019 09

测试没通过,我们检查一下代码,发现我们 parseHeaders 的代码逻辑漏洞,我们只考虑了第一个 “:” 号,没考虑后半部分的字符串内部也可能有 “:”,按我们现有的逻辑就会把字符串中 “:” 后面部分都截断了。

因此我们修改 parseHeaders 的实现逻辑。

  1. export function parseHeaders(headers: string): any {
  2. let parsed = Object.create(null)
  3. if (!headers) {
  4. return parsed
  5. }
  6. headers.split('\r\n').forEach(line => {
  7. let [key, ...vals] = line.split(':')
  8. key = key.trim().toLowerCase()
  9. if (!key) {
  10. return
  11. }
  12. let val = vals.join(':').trim()
  13. parsed[key] = val
  14. })
  15. return parsed
  16. }

这样我们再重新跑测试,就会通过了。

url 模块测试

test/helpers/url.spec.ts

  1. import { buildURL, isAbsoluteURL, combineURL, isURLSameOrigin } from '../../src/helpers/url'
  2. describe('helpers:url', () => {
  3. describe('buildURL', () => {
  4. test('should support null params', () => {
  5. expect(buildURL('/foo')).toBe('/foo')
  6. })
  7. test('should support params', () => {
  8. expect(
  9. buildURL('/foo', {
  10. foo: 'bar'
  11. })
  12. ).toBe('/foo?foo=bar')
  13. })
  14. test('should ignore if some param value is null', () => {
  15. expect(
  16. buildURL('/foo', {
  17. foo: 'bar',
  18. baz: null
  19. })
  20. ).toBe('/foo?foo=bar')
  21. })
  22. test('should ignore if the only param value is null', () => {
  23. expect(
  24. buildURL('/foo', {
  25. baz: null
  26. })
  27. ).toBe('/foo')
  28. })
  29. test('should support object params', () => {
  30. expect(
  31. buildURL('/foo', {
  32. foo: {
  33. bar: 'baz'
  34. }
  35. })
  36. ).toBe('/foo?foo=' + encodeURI('{"bar":"baz"}'))
  37. })
  38. test('should support date params', () => {
  39. const date = new Date()
  40. expect(
  41. buildURL('/foo', {
  42. date: date
  43. })
  44. ).toBe('/foo?date=' + date.toISOString())
  45. })
  46. test('should support array params', () => {
  47. expect(
  48. buildURL('/foo', {
  49. foo: ['bar', 'baz']
  50. })
  51. ).toBe('/foo?foo[]=bar&foo[]=baz')
  52. })
  53. test('should support special char params', () => {
  54. expect(
  55. buildURL('/foo', {
  56. foo: '@:$, '
  57. })
  58. ).toBe('/foo?foo=@:$,+')
  59. })
  60. test('should support existing params', () => {
  61. expect(
  62. buildURL('/foo?foo=bar', {
  63. bar: 'baz'
  64. })
  65. ).toBe('/foo?foo=bar&bar=baz')
  66. })
  67. test('should correct discard url hash mark', () => {
  68. expect(
  69. buildURL('/foo?foo=bar#hash', {
  70. query: 'baz'
  71. })
  72. ).toBe('/foo?foo=bar&query=baz')
  73. })
  74. test('should use serializer if provided', () => {
  75. const serializer = jest.fn(() => {
  76. return 'foo=bar'
  77. })
  78. const params = { foo: 'bar' }
  79. expect(buildURL('/foo', params, serializer)).toBe('/foo?foo=bar')
  80. expect(serializer).toHaveBeenCalled()
  81. expect(serializer).toHaveBeenCalledWith(params)
  82. })
  83. test('should support URLSearchParams', () => {
  84. expect(buildURL('/foo', new URLSearchParams('bar=baz'))).toBe('/foo?bar=baz')
  85. })
  86. })
  87. describe('isAbsoluteURL', () => {
  88. test('should return true if URL begins with valid scheme name', () => {
  89. expect(isAbsoluteURL('https://api.github.com/users')).toBeTruthy()
  90. expect(isAbsoluteURL('custom-scheme-v1.0://example.com/')).toBeTruthy()
  91. expect(isAbsoluteURL('HTTP://example.com/')).toBeTruthy()
  92. })
  93. test('should return false if URL begins with invalid scheme name', () => {
  94. expect(isAbsoluteURL('123://example.com/')).toBeFalsy()
  95. expect(isAbsoluteURL('!valid://example.com/')).toBeFalsy()
  96. })
  97. test('should return true if URL is protocol-relative', () => {
  98. expect(isAbsoluteURL('//example.com/')).toBeTruthy()
  99. })
  100. test('should return false if URL is relative', () => {
  101. expect(isAbsoluteURL('/foo')).toBeFalsy()
  102. expect(isAbsoluteURL('foo')).toBeFalsy()
  103. })
  104. })
  105. describe('combineURL', () => {
  106. test('should combine URL', () => {
  107. expect(combineURL('https://api.github.com', '/users')).toBe('https://api.github.com/users')
  108. })
  109. test('should remove duplicate slashes', () => {
  110. expect(combineURL('https://api.github.com/', '/users')).toBe('https://api.github.com/users')
  111. })
  112. test('should insert missing slash', () => {
  113. expect(combineURL('https://api.github.com', 'users')).toBe('https://api.github.com/users')
  114. })
  115. test('should not insert slash when relative url missing/empty', () => {
  116. expect(combineURL('https://api.github.com/users', '')).toBe('https://api.github.com/users')
  117. })
  118. test('should allow a single slash for relative url', () => {
  119. expect(combineURL('https://api.github.com/users', '/')).toBe('https://api.github.com/users/')
  120. })
  121. })
  122. describe('isURLSameOrigin', () => {
  123. test('should detect same origin', () => {
  124. expect(isURLSameOrigin(window.location.href)).toBeTruthy()
  125. })
  126. test('should detect different origin', () => {
  127. expect(isURLSameOrigin('https://github.com/axios/axios')).toBeFalsy()
  128. })
  129. })
  130. })

这里要注意的是,我们使用了 jest.fn 去模拟了一个函数,这个也是在编写 Jest 测试中非常常用的一个 API。

至此,我们就实现了 ts-axioshelpers 目录下所有模块的测试,并把该目录下的测试覆盖率达到了近乎 100% 的覆盖率。下面的章节我们就开始测试 ts-axios 的核心流程,针对不同的 feature 去编写单元测试了。