Jest 学习02 - API、测试异步代码、模拟计时器

本文详细介绍了Jest测试框架的API,包括test函数的各种形式,常用断言如toBe、toEqual及其区别,以及异步测试的处理方法,如callback、Promise和async/await。同时,讲解了模拟计时器的使用,如jest.useFakeTimers、jest.runAllTimers等,以及如何处理计时器循环和泄露问题。内容涵盖了Jest测试的基础和进阶技巧。

Jest API

官方文档:全局设定 · Jest

在测试文件中,Jest 将所有 API 和对象放入全局环境中。开发者不需要导入任何内容即可使用它们。但是,如果您喜欢显式导入,则可以:

import { describe, expect, test } from '@jest/globals'

Test 函数

test 函数用于创建测试用例。

Jest 要求每一个测试文件至少包含一个测试用例,否则会报错,例如创建一个空的测试文件 global.test.js,运行 jest 结果:

在这里插入图片描述

test 函数别名: it(name, fn, timeout),以下两个测试用例相同:

test('global test', () => {
    console.log('global')
})

it('global test', () => {
    console.log('global')
})

Test 函数包括:

  • test(name, fn, timeout)
  • test.concurrent(name, fn, timeout)
  • test.concurrent.each(table)(name, fn, timeout)
  • test.concurrent.only.each(table)(name, fn)
  • test.concurrent.skip.each(table)(name, fn)
  • test.each(table)(name, fn, timeout)
  • test.only(name, fn, timeout)
  • test.only.each(table)(name, fn)
  • test.skip(name, fn)
  • test.skip.each(table)(name, fn)
  • test.todo(name)

Expect 匹配器

官方文档:

在编写测试时,通常需要检查值是否满足某些条件,即“断言”。 Expect 使您可以访问许多“匹配器”,以使您可以验证不同的内容。

常用匹配器:

// 常规
test('Common Matchers', () => {
  // toBe
  // 使用 Object.is 精确比较值(对象只匹配引用)
  expect(2 + 2).toBe(4)
  expect('hello').toBe('hello')
  expect(true).toBe(true)
  const foo = { age: 18 }
  // 失败:对象只匹配引用
  // expect(foo).toBe({ age: 18 })

  // toEqual
  // 递归(使用 Object.is)比较对象实例的所有属性(深度对比)
  expect(foo).toEqual({ age: 18 })
  // 注意:toEqual 不会对 Error 进行深度对比,只会对比 message 属性是否相等
  expect(new Error('报错了')).toEqual(new Error('报错了'))
})

// 真实性
test('Truthiness', () => {
  const n = null
  // toBeNull 只匹配 null
  expect(n).toBeNull()
  // toBeDefined 与 toBeUndefined 相反
  expect(n).toBeDefined()
  // toBeUndefined 只匹配 undefined
  expect(n).not.toBeUndefined()
  // toBeTruthy 匹配 if 语句视为 true 的内容
  expect(n).not.toBeTruthy()
  // toBeFalsy 匹配 if 语句视为 false 的内容
  expect(n).toBeFalsy()

  const z = 0
  expect(z).not.toBeNull()
  expect(z).toBeDefined()
  expect(z).not.toBeUndefined()
  expect(z).not.toBeTruthy()
  expect(z).toBeFalsy()
})

// 数字
test('Numbers', () => {
  const value = 2 + 2
  // 大于
  expect(value).toBeGreaterThan(3)
  // 大于等于
  expect(value).toBeGreaterThanOrEqual(4)
  // 小于
  expect(value).toBeLessThan(5)
  // 小于等于
  expect(value).toBeLessThanOrEqual(4.5)

  // toBe 和 toEqual 在数字上是等价的
  expect(value).toBe(4)
  expect(value).toEqual(4)

  const float = 0.1 + 0.2
  // JS 浮点运算会有 round 错误,存在微小误差,使用 toBe 或 toEqual 对比浮点数可能不会正常工作
  // expect(value).toBe(0.3)
  console.log(float) // 0.30000000000000004

  // 对于浮点对比,可以使用 toBeCloseTo 进行近似相等
  // 可以传入第二个参数限制小数点后要检查的位数,默认是 2
  expect(float).toBeCloseTo(0.3)
  // toBeCloseTo 是为了解决浮点错误问题,所以它不支持 bigInt 类型(通常用于定义超出 JS 最大数字 2^53 - 1 的整数)的值
  // expect(1n).toBeCloseTo(1n)
})

// 字符串
test('Strings', () => {
  // toMatch 正则表达式匹配
  expect('team').not.toMatch(/I/)
  expect('Christoph').toMatch(/stop/)
})

// 数组和迭代类型
test('Arrays and iterables', () => {
  const shoppingList = ['diapers', 'kleenex', 'trash bags', 'paper towels', 'milk']

  // toContain 用于检查数组或迭代类型是否包含指定项
  expect(shoppingList).toContain('milk')
  expect(new Set(shoppingList)).toContain('milk')
})

// 异常
test('Exceptions', () => {
  function compileAndroidCode() {
    throw new Error('you are using the wrong JDK')
    // throw 'you are using the wrong JDK'
  }
  // toThrow 可以测试调用某个特定函数时是否抛出异常
  // 注意:传递的必须是一个函数,jest 会调用它
  expect(compileAndroidCode).toThrow()
  // or(建议)
  expect(() => compileAndroidCode()).toThrow()

  // 可以传递可选参数来测试是否抛出了特定错误:
  // 1. 正则表达式:错误消息的正则匹配
  expect(() => compileAndroidCode()).toThrow(/JDK/)

  // 2. 字符串:错误消息是否包含字符串
  expect(() => compileAndroidCode()).toThrow('wrong JDK')

  // 3. Error 实例:匹配错误消息是否一致
  expect(() => compileAndroidCode()).toThrow(new Error('you are using the wrong JDK'))

  // 4. Error 类:匹配是否抛出的 Error 实例
  expect(() => compileAndroidCode()).toThrow(Error)
})

describe 函数

describe 创建一个将几个相关测试组合在一起的块。

const myBeverage = {
  delicious: true,
  sour: false,
};

describe('my beverage', () => {
  test('is delicious', () => {
    expect(myBeverage.delicious).toBeTruthy();
  });

  test('is not sour', () => {
    expect(myBeverage.sour).toBeFalsy();
  });
});

describe 函数可以将相关的测试用例进行分组,在测试失败的时候可以获得更直观的提示,除此之外还可以使用作用域,定制私有的生命周期钩子等好处。

describe 函数包括:

  • describe(name, fn)
  • describe.each(table)(name, fn, timeout)
  • describe.only(name, fn)
  • describe.only.each(table)(name, fn)
  • describe.skip(name, fn)
  • describe.skip.each(table)(name, fn)

生命周期钩子

生命周期钩子主要用于在测试运行完成前后执行一些自定义内容。

beforeAll(() => {
  console.log('在当前测试文件的所有用例运行前执行')
})

beforeEach(() => {
  console.log('在当前测试文件的所每个用例运行前执行')
})

生命周期钩子包括:

  • afterAll(fn, timeout)
  • afterEach(fn, timeout)
  • beforeAll(fn, timeout)
  • beforeEach(fn, timeout)

Jest 对象

官方文档:The Jest Object

jest 对象自动位于每个测试文件中的作用域中。 jest 对象中的方法有助于创建模拟(mocks),并让您控制 Jest 的整体行为。也可以通过从 import {jest} from '@jest/globals' 导入。

测试异步代码

官方文档:Testing Asynchronous Code

Jest 在测试 JavaScript 中的异步代码时,需要知道正在测试的代码何时完成,才能继续进行另一个测试。

Callbacks

最常见的异步模式是 callback 回调。

常见的错误做法:

// 测试异步代码
function fetchData(callback) {
  setTimeout(() => {
    callback({ foo: 'bar' })
  }, 1000)
}

// 错误用例
test('async test', () => {
  fetchData(data => {
    expect(data).toEqual({ foo: 'bar' })
    expect(data).toEqual(1) // 仍会测试通过
  })
})

上面的测试没有按照预期工作,是因为默认情况下,Jest 测试在代码执行结束后立即完成,不会等待异步代码执行结果。

所以该测试用例,在调用回调之前,fetchData 就会调用结束,测试就会 complete(完成)。

解决方法是,测试函数接受一个名为 done 的参数,Jest 将在完成测试之前等待 done() 被调用。

注意:因为 Jest 会等待 done,所以一些错误的异步测试用例也会等待相同的时间,这可能会导致这些用例按照期望工作,但使用方式仍是错误的。

建议:虽然 done 的作用是让测试等待异步代码执行完毕,不过还是建议将 done() 写在断言调用之后。

// 测试异步代码
function fetchData(callback) {
  setTimeout(() => {
    callback({ foo: 'bar' })
  }, 1000)
}

// 正确用例
test('async test', done => {
  fetchData(data => {
    try {
      expect(data).toEqual(1)
      done()
    } catch (error) {
      done(error)
    }
  })
})

如果 done() 一直不被调用,测试将失败(带有超时错误)。

如果 expect 语句失败,它会抛出一个错误,并且不会调用 done(),所以必须将 expect 包裹在一个 try-catch 块中,并将 catch 块中的错误传递给 done,否则我们只会得到一个超时错误,而不会显示 expect(data) 接受到的值。

注意:done() 不应与 Promises 混合使用,因为这会导致测试中的内存泄漏。

Promises

如果你的代码使用 Promise,除了使用 done,还有一种更直接的方法来处理异步测试。

返回一个 Promise

从测试中返回一个 Promise,Jest 将等待该 Promise 直到决议(resolve)。如果 Promise 被拒绝(rejected),测试将自动失败。

注意:两种方式不能混用。

// 测试异步代码
function fetchData(isRejected) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (isRejected) {
        reject('This throws an error')
      } else {
        resolve({ foo: 'bar' })
      }
    }, 1000)
  })
}

// done 方式
test('async test', done => {
  fetchData().then(data => {
    try {
      expect(data).toEqual({ foo: 'bar' })
      // expect(data).toEqual(1)
      done()
    } catch (error) {
      done(error)
    }
  })
})

// promise 方式
test('promise', () => {
  return fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
    // expect(data).toEqual(1)
  })
})

期望一个拒绝的 Promise

如果你期望(expect)一个 Promise 被拒绝(rejected),则会使用 .catch() 方法捕获。

如果仅期望拒绝,而不期望成功(不使用 .then() 方法),请确保使用 expect.assertions(number) 方法,验证测试用例是否调用了一定数量的断言。否则 fulfilled(或称 resolved) 的 Promise 不会使测试失败。

使用 expect.assertions(number) 另一个好处是可以确保你写的异步代码测试用例是否正确。

test('没有断言被调用', () => {
  // fetchData() 返回一个成功的 Promise,不会进入 catch
  // 又因为没有定义 then,不需进行任何校验,所以该测试将成功
  return fetchData()
    // .then(data => {
    //   expect(data).toEqual({ foo: 'bar' })
    // })
    .catch(data => {
      expect(data).toMatch('error')
    })
})

test('指定断言调用数量', () => {
  // 期望会有且只有一个断言被调用
  expect.assertions(1)

  // 因为没有进入 catch,所以没有调用任何断言,该测试结果将失败
  return fetchData()
    .catch(data => {
      expect(data).toMatch('error')
    })
})

.resolves / .rejects

还有一个方式是使用 expect 语句中的 .resolves/.rejects 匹配器。

  • .resolves:Jest 会等待 Promise 被 resolve,如果被拒绝,则测试自动失败
  • .rejects:与 .resolves 相反,如果 Promise 被兑现,则测试自动失败
test('.resolves', () => {
  return expect(fetchData()).resolves.toEqual({ foo: 'bar' })
})

test('.rejects', () => {
  return expect(fetchData(true)).rejects.toMatch('error')
})
  • 将函数返回的 Promise 传递给 expect
  • 确保测试将断言返回,如果忽略了 return,测试将在 fetchData() 返回的 Promise 得到决议之前完成。

Async/Await

或者你还可以在测试用例中使用 async/await 处理 Promise。

在传递给测试用例的函数前使用 async 关键字,在函数体中使用 await

test('async/await', async () => {
  const data = await fetchData()
  expect(data).toEqual({ foo: 'bar' })
})

你也可以将 async/await.resolves/.rejects 结合使用,无需 return 断言。

test('async/await with .resolves/.rejects', async () => {
  await expect(fetchData()).resolves.toEqual({ foo: 'bar' })
  await expect(fetchData(true)).rejects.toMatch('error')
})

async 和 await 实际上是上面 Promises 示例中使用的相同逻辑的语法糖,具体如何使用,取决于你觉得哪种风格能让你的测试更简单。

Timer Mocks 模拟计时器

官方文档:Timer Mocks

原生 timer 方法(即 setTimeoutsetIntervalDate.now 等)不太适合测试环境,因为它们依赖于实时时间。

使用可以手动“推进”时间的替代物来模拟这些功能会很有帮助,它会压缩实际等待的时间,确保你的测试快速运行。

依赖于计时器的测试仍将按照顺序解析,但会更快(React 例子)。

大部分测试框架,包括 Jestsinonlolex 都允许你在测试中模拟计时器。

有些时候可能你不想要模拟计时器。例如,在你测试动画时,或是交互端对时间较为敏感的情况下(如 API 访问速率限制器)。具有计时器模拟的库允许你在每个测试/套件上启用或禁用这个功能,因此你可以明确地选择这些测试的运行方式。

基本用法

注意:Jest 默认每个测试设置了 5 秒超时,可以修改配置项 testTimeout(默认 5000)。

下面的测试没有等待异步代码执行,所以测试失败:

// 测试异步代码
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ foo: 'bar' })
    }, 10000)
  })
}

test('timer mock', () => {
  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })
})

可以使用 doneasync/await 等方式处理,但是需要等待至少10秒才会测试完成,如果使用模拟计时器,可以加快计时时间:

// 使用假的计时器
jest.useFakeTimers()

test('timer mock', () => {
  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 立即执行所有宏任务队列和微任务队列
  jest.runAllTimers()
})

通过调用 jest.useFakeTimers() 启用假的计时器,它模拟了 setTimeout 和其他计时器函数。可以通过调用 jest.useRealTimers() 恢复默认的计时器行为。

开启假的计时器后,通过调用 jest.runAllTimers() 立即执行所有宏任务队列和微任务队列。

**注意:**使用了模拟计时器后,处理使用了计时器的异步代码可以不使用 done 实现等待的目的,即便使用也没问题。

但是不能使用 Promises 方式去处理,这样模拟计时器将被忽略,测试用例会等待真实的时间才会完成。

运行挂起的计时器

还有一些情况下,您可能会有一个递归的计时器(不同于 setInterval),在自己的计时器回调中设置新的计时器,例如:

// 测试异步代码
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ foo: 'bar' })
      // 循环调用
      fetchData()
    }, 6000)
  })
}

test('timer mock', () => {
  // 使用假的计时器
  jest.useFakeTimers()

  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 立即执行所有宏任务队列和微任务队列
  jest.runAllTimers()
})

对于这种情况,运行所有计时器将是一个无休止的循环,会引发以下错误:

Aborting after running 100000 timers, assuming an infinite loop!

对于此类测试,可以使用 jest.runOnlyPendingTimers() 仅执行当前挂起的宏任务:

test('timer mock', () => {
  // 使用假的计时器
  jest.useFakeTimers()

  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 仅立即执行当前挂起的宏任务
  jest.runOnlyPendingTimers()
})

如果有任何当前挂起的宏任务调度新宏任务,则此调用不会执行这些新任务。

按时间加快计时

jest.runAllTimers()jest.runOnlyPendingTimers() 是立即执行任务,相当于把计时器直接快进到结束。

你也可以通过调用 jest.advanceTimersByTime(msToRun) 快进指定的时间,所有在这个时间内入列的宏任务都将被执行,如果这些宏任务中在同一时间内入列了新的宏任务,则也会执行这些新的宏任务。直到队列中没有这个时间内入列的宏任务。

注意:jest.advanceTimersByTime(msToRun) 仅执行宏任务队列。

// 测试异步代码
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ foo: 'bar' })
      // 循环调用
      fetchData()
    }, 6000)
  })
}

test('timer mock', () => {
  // 使用假的计时器
  jest.useFakeTimers()

  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 仅立即执行指定时间内入列的宏任务
  jest.advanceTimersByTime(7000)
})

也可以多次调用,Jest 会将时间累积:

test('timer mock', () => {
  // 使用假的计时器
  jest.useFakeTimers()

  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 此行之前定义的断言,会立即执行 8 秒内入列的宏任务
  jest.advanceTimersByTime(4000)
    
  // 可以在中间增加断言,但仅会执行 4 秒内入列的宏任务

  jest.advanceTimersByTime(4000)
})

计时器泄露

你可以在任意位置调用 jest.useFakeTimers()jest.useRealTimers()(顶层、或 testit) 块等),这是一个全局的操作,将影响同一文件中的其他测试用例。

例如:

// 测试异步代码
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ foo: 'bar' })
    }, 6000)
  })
}

test('timer mock1', () => {
  // 使用模拟的计时器
  jest.useFakeTimers()

  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })

  // 执行 4 秒内入列的宏任务,所以当前测试用例会失败
  jest.advanceTimersByTime(4000)
})

test('timer mock2', () => {
  // 此时仍在使用模拟计时器
    
  // 必须执行一个断言
  expect.assertions(1)
  fetchData().then(data => {
    expect(data).toEqual({ foo: 'bar' })
  })
  
  // 计时器已经经过上面的测试用例加快了4秒,这里又加快4秒,所以会执行8秒内入列的宏任务
  jest.advanceTimersByTime(4000)

})

如果你计划在所有测试中不使用假计时器,则需要手动清理,否则假计时器将在测试中泄露:

afterEach(() => {
  jest.useRealTimers()
})

test('do something with fake timers', () => {
  jest.useFakeTimers()
  // ...
})

test('do something with real timers', () => {
  // ...
})

总结

// 使用模拟定时器
jest.useFakeTimers()

// 验证定时器函数被调用的次数
expect(setTimeout).toHaveBeenCalledTimes(1)

// 验证定时器的时间是 1s
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000)

// 快进所有定时器结束
jest.runAllTimers()

// 解决定时器循环问题
jest.runOnlyPendingTimers()

// 快进定时器到指定时间
jest.advanceTimersByTime(1000)

// 清除所有定时器
jest.clearAllTimers()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值