jest 中的模拟
被代码会依赖一些外部环境,比如 http 接口,npm 模块、数据库等,集成这些环境往往会使得测试用例不可控,真实环境也许是缓慢和脆弱的,比如真实环境要求定时器的间隔为 20 分钟,测试不可能等待 20 分钟,需要使用代码模拟一个稳定的环境,jest 可模拟常见的"环境",比如回调函数、定时器、数据库等。
通过模拟,可以使得测试用例更加可控,更加稳定,更加快速,且能方便的知道依赖的模块的调用情况:
- 是否被调用
- 调用次数
- 调用时的参数
- 调用时的返回值
- 调用时的 this
- 调用顺序
jest 有三种创建模拟函数的方式:
- jest.fn() -- 模拟函数
- jest.spyOn() -- 模拟对象的方法
- jest.mock() -- 模拟模块
先看 jest.fn
jest.fn
jest.fn 是最简单的模拟函数的方法。
模拟一个无操作的函数
it('simple jest.fn', () => {
const mockFn = jest.fn()
mockFn()
expect(mockFn).toBeCalled()
})
输出 mockFn 看看:
[Function: mockConstructor] {
_isMockFunction: true,
getMockImplementation: [Function(anonymous)],
mock: [Getter / Setter],
mockClear: [Function(anonymous)],
mockReset: [Function(anonymous)],
mockRestore: [Function(anonymous)],
mockReturnValueOnce: [Function(anonymous)],
mockResolvedValueOnce: [Function(anonymous)],
mockRejectedValueOnce: [Function(anonymous)],
mockReturnValue: [Function(anonymous)],
mockResolvedValue: [Function(anonymous)],
mockRejectedValue: [Function(anonymous)],
mockImplementationOnce: [Function(anonymous)],
mockImplementation: [Function(anonymous)],
mockReturnThis: [Function(anonymous)],
mockName: [Function(anonymous)],
getMockName: [Function(anonymous)]
}
是一个函数,有很多属性。
模拟函数实现
it('simple jest.fn give implement', () => {
const mockFn = jest.fn(() => 'hello')
const result = mockFn()
expect(mockFn).toBeCalled()
expect(result).toBe('hello')
const mockFn2 = jest.fn((a, b) => a + b)
const result2 = mockFn2(1, 2)
expect(mockFn2).toBeCalledTimes(1)
expect(mockFn2).toBeCalledWith(1, 2)
})
还可以这样:
it('simple jest.fn give implement - 2', () => {
const mockFn = jest.fn()
mockFn.mockImplementation(() => 'hello')
const result = mockFn()
expect(mockFn).toBeCalled()
expect(result).toBe('hello')
})
模拟出函数后,就可使用 toBeCalled
、 toBeCalledTimes
和 toBeCalledWith
匹配器断言执行情况。
如果不关系某个参数的值,可以使用
expect.any
匹配器。
describe('The mock function', () => {
it('should be called', () => {
mockFunction(10, 5)
expect(mockFunction).toBeCalledWith(expect.anything(), 5)
})
})
expect.anything() 匹配除了null 和 undefined 之外的任何值。
上面的三个例子比较简单,下面看看复杂一点的例子。
// MontyPython.js
export default class MontyPython {
callFnWithTheMeaningOfLife(fn) {
fn(42)
}
getTheMeaningOfLife() {
return Math.random() * 100
}
}
需要测试回调 fn 是否被调用,且参数为 42。
// MontyPython.spec.js
import MontyPython from './MontyPython'
it('callFnWithTheMeaningOfLife', () => {
const mockFn = jest.fn()
const montyPython = new MontyPython()
montyPython.callFnWithTheMeaningOfLife(mockFn)
expect(mockFn).toHaveBeenCalledWith(42)
})
参数是固定的 42,能预测,但是如果参数或者返回值是随机的,就无法预测了,比如 getTheMeaningOfLife
, 这时候就需要模替换掉 Math.random
,random 是对象上的一个方法,所以需要模拟对象的方法, jest.spyOn
出场。
jest.spyOn
getTheMeaningOfLife
的测试用例:
// MontyPython.spec.js
it('getTheMeaningOfLife', () => {
const mockRandom = jest.spyOn(Math, 'random')
mockRandom.mockImplementation(() => 10)
const montyPython = new MontyPython()
const result = montyPython.getTheMeaningOfLife()
expect(mockRandom).toHaveBeenCalled()
expect(result).toBe(10 * 100)
mockRandom.mockRestore()
})
jest.spyOn 会返回一个模拟对象,可以使用
mockImplementation
重写函数的实现。
mockRandom.mockRestore() 会恢复原来的实现。
还可以这样模拟返回值:
mockRandom.mockReturnValue(10)
jest.mock
学习了如何模拟函数和对象的方法,现在看看如何模拟模块。
jest 提供了 mock
和 doMock
用于模拟模块。
jest.mock('path/to/file', () => ({
__esModule: true // 标明是 esm
// 一个返回对象的函数:工厂函数
}))
模拟命名导出
有一 config.ts
模块
// config.ts
const CAPITALIZE = true
export {
CAPITALIZE
}
有一 sayHello.ts
模块使用了 config
// sayHello.ts
import { CAPITALIZE } from './config'
export const sayHello = (name: string) => {
let result = 'Hi, '
if (CAPITALIZE) {
result += name[0].toUpperCase() + name.substring(1, name.length)
} else {
result += name
}
return result
}
如何测试 sayHello 函数呢?
sayHello 有两个分支,至少需要两个用例来覆盖,而这分支里用到了外部依赖 CAPITALIZE
, 需要模拟 CAPITALIZE
的值。
// sayHello.test.ts
import {
sayHello
} from './sayHello'
// NOTE 命名导出,合并命名导出到一个对象上,方便在每次用例中重置
import * as config from './config'
jest.mock('./config', () => ({
__esModule: true,
CAPITALIZE: void 0,
}))
// 解决 ts 无法重写导入属性的问题
const mockConfig = config as {
CAPITALIZE: boolean
}
describe('sayHello', () => {
test('Capitalizes name if config requires that', () => {
// NOTE 无法为"CAPITALIZE"赋值,因为它是只读属性。
// NOTE ts 视导入为常量,且对象的属性是只读的
mockConfig.CAPITALIZE = true
expect(sayHello('john')).toBe('Hi, John')
})
test('does not capitalize name if config does not require that', () => {
mockConfig.CAPITALIZE = false
expect(sayHello('john')).toBe('Hi, john')
})
})
ts 中会把引入的模块视为常量。能重置它的值,否则报错:无法为"CAPITALIZE"赋值,因为它是只读属性。
使用
const mockConfig = config as {CAPITALIZE: boolean}
解决。
使用
import * as
把所有命名导出,合并到一个对象上,方便模拟。
正常的导入是不能少的,即
import * as config from './config'
必须有,否则 jest.mock 不知道模拟哪个模块。
模拟默认导出
有一模块:
// sayHello-3.ts
import shouldCapitalize from './config-default-fn'
export const sayHello = (name: string) => {
let result = 'Hi, '
if (shouldCapitalize()) {
result += name[0].toUpperCase() + name.substring(1, name.length)
} else {
result += name
}
return result
}
依赖模块 config-default-fn
:
// config-default-fn.ts
const shouldCapitalize = () => true
export default shouldCapitalize
要如何模拟这个命名导出呢?
default 是特殊命名导出,其实和普通命名导出一样,就是重写这个属性。
看测试用例:
// sayHello-3.test.ts
import { sayHello } from './sayHello-3'
// NOTE 默认导出,是一个函数
import * as config from './config-default-fn'
jest.mock('./config-default-fn', () => ({
__esModule: true,
default: jest.fn(),
}))
// NOTE 手动指定类型,去掉重写报错
const shouldCapitalizeMock = config.default as jest.Mock
describe('sayHello', () => {
test('Capitalizes name if config requires that', () => {
shouldCapitalizeMock.mockReturnValue(true)
expect(sayHello('john')).toBe('Hi, John')
})
test('does not capitalize name if config does not require that', () => {
shouldCapitalizeMock.mockImplementation(() => false)
expect(sayHello('john')).toBe('Hi, john')
})
})
在两个用例里,以指定返回值和重新函数实现的方式,替换了 shouldCapitalize
。
依赖是默认的导出的变量呢?
比如:
const CAPITALIZE = true
export default CAPITALIZE
可以这样模拟:
import * as config from './config-default'
jest.mock('./config-default', () => ({
__esModule: true,
default: void 0,
}))
const mockConfig = config as { default: boolean }
在用例中可重置它的值。
同时模拟命名导出和默认导出
有一模块如下:
function say(greeting = 'Hello', name = 'World!') {
// console.log(`${greeting}, ${name}!`)
return `${greeting}, ${name}!`
}
function sum(a: number, b: number) {
return a + b
}
export { say, sum }
// import {sum} from './mockExportObj'
// sum()
export default { say }
// import exportObj from './mockExportObj'
// exportObj.say()
可以这样模拟:
import exportObj, { sum } from './mockExportObj'
// NOTE 模拟默认导出和命名导出
jest.mock('./mockExportObj', () => {
let mockSum = jest.fn().mockImplementation((a, b) => '' + a + '' + b)
return {
__esModule: true, // this property makes it work
default: {
say: jest.fn().mockImplementation((greeting = 'Hello', name = 'World') => {
return `${greeting},${name}`
}),
},
sum: mockSum, //: jest.fn().mockImplementation((a, b) => '' + a + '' + b),
}
})
完整的测试用例:
import exportObj, { sum } from './mockExportObj'
// NOTE 模拟默认导出和命名导出
jest.mock('./mockExportObj', () => {
let mockSum = jest.fn().mockImplementation((a, b) => '' + a + '' + b)
return {
__esModule: true, // this property makes it work
default: {
say: jest.fn().mockImplementation((greeting = 'Hello', name = 'World') => {
return `${greeting},${name}`
}),
},
sum: mockSum,
}
})
// console.log(result, 'zqj log')
describe('mock 模块默认对象', () => {
it('exportObj.say', () => {
jest.spyOn(exportObj, 'say')
expect(exportObj.say()).toBe('Hello,World')
expect(exportObj.say('你好')).toBe('你好,World')
expect(exportObj.say('你好', 'Jest')).toBe('你好,Jest')
expect(exportObj.say).toHaveBeenCalledTimes(3)
})
it('测试命名导出', () => {
expect(sum(1, 2)).toBe('12')
// 直接断言执行次数,不用 jest.spyOn
expect(sum).toHaveBeenCalledTimes(1)
})
it('重写命名导出', () => {
// @ts-ignore
sum.mockImplementation((a, b) => a / b)
expect(sum(4, 2)).toBe(2)
expect(sum).toHaveBeenCalledTimes(1)
expect(sum).toHaveBeenCalledWith(4, 2)
})
it('重写模块中的某个函数', () => {
jest.spyOn(exportObj, 'say')
// @ts-ignore
exportObj.say.mockImplementation((greeting = 'Hello', name = 'World') => {
return `${greeting} + ${name}`
})
expect(exportObj.say()).toBe('Hello + World')
expect(exportObj.say('你好')).toBe('你好 + World')
expect(exportObj.say('你好', 'Jest')).toBe('你好 + Jest')
expect(exportObj.say).toHaveBeenCalledTimes(3)
})
})
想要在某个用例里模拟呢?
jest.doMock
提供了在某个用例里模拟的能力。
test('jest.resetModules', () => {
// NOTE jest.doMock 会覆盖 jest.mock
// const mockDefault =
jest.doMock('./config-default-fn', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => false),
}))
expect(sayHello('john')).toBe('Hi, john')
// NOTE 重置模块
jest.resetModules()
})
它会覆盖外部的 jest.mock
。
jest.mock 和 jest.doMock 的区别是什么?
带工厂函数的 mock 会提升到文件的顶部,会代替相关 import
,会自动清除 mock,而 doMock 用在 test 里,不会提升到文件顶部,需要手动清除 mock。
参考:
Difference between jest.mock and jest.doMock
在某个用例里不想模拟,而是调用真实的模块呢?
jest.dontMock
可停止模拟。
test("don't mock", () => {
jest.dontMock('./config-default-fn')
// sayHello 内部调用真实的 shouldCapitalize
// BUG 没有通过断言
expect(sayHello('john')).toBe('Hi, John')
})
jest.unmock 可以终止
jest.mock
, 几乎不用。
如何模拟模块中一部分呢?
有一模块如下:
// math.ts
function sum(firstNumber: number, secondNumber: number) {
return firstNumber + secondNumber
}
function subtract(firstNumber: number, secondNumber: number) {
return firstNumber - secondNumber
}
export { sum, subtract }
在模拟 math 模块时,只想模拟 subtract
函数,不想模拟 sum
函数,怎么办?
jest.requireActual
可以导入真实的模块,然后模拟部分函数。
// math.test.ts
import * as math from './math'
jest.mock('./math', () => {
return {
...jest.requireActual('./math'),
subtract: jest.fn(),
}
})
const mockMath = math as jest.Mocked<typeof math>
describe('math.ts', () => {
it('should add two numbers', () => {
expect(math.sum(1, 2)).toBe(3)
})
it('重新实现 subtract 1', () => {
mockMath.subtract.mockReturnValueOnce(1)
expect(math.subtract(1, 2)).toBe(1)
})
it('重新实现 subtract 2', () => {
mockMath.subtract.mockImplementationOnce((a, b) => a + b)
expect(math.subtract(1, 2)).toBe(3)
})
})
jest.mock('path/to/file'),没有工厂函数,可实现全局模拟
有一模块如下:
// tests/units/mockGlobal.ts
import { getProfile } from '../../https'// 从 src/https/index.ts 导入
async function mockGlobal() {
const profile = await getProfile()
return profile
}
https 模块如下:
// src/https/index.ts
import axios from 'axios'
function getProfile() {
return axios.get('http://localhost:3001/profile').then(res => res.data)
}
export { getProfile }
在 src/https
目录下创建 __mocks__/index.ts
导出同名函数
// src/https/__mocks__/index.ts
import dbJson from '../../../db.json'
function getProfile() {
return jest.fn().mockResolvedValue(dbJson.profile)
}
export {
getProfile
}
在测试用例中使用 jest.mock('../../https')
,jest 会自动找到 __mocks__/index.ts
模块,实现全局模拟。
// tests/units/mockGlobal.test.ts
import { getProfile } from '../../https'
import dbJson from '../../../db.json'
import { mockGlobal } from './globalMock'
jest.mock('../../https')
describe('mock global', () => {
it('重写模拟函数', async () => {
const mockGetProfile = getProfile as jest.MockedFunction<typeof getProfile>
const mockData = dbJson.profile
mockGetProfile.mockResolvedValue(mockData)
const profile = await getProfile()
expect(profile).toEqual(mockData)
})
it('mockGlobal', async () => {
const mockData = dbJson.profile
const profile = await mockGlobal()
expect(profile).toEqual(mockData)
})
})
jest.mock 不仅可全局模拟项目里的模块,还能模拟 npm 模块。
模拟常见的 http 库 axios
// tests/units/mockAxios.ts
import axios from 'axios'
function getTodoList() {
return axios.get('https://jsonplaceholder.typicode.com/todos').then(res => res.data)
}
export { getTodoList }
测试用例:
import axios from 'axios'
import { getTodoList } from './mockAxios'
// 模拟 导入的 axios
jest.mock('axios')
describe('mock axios', () => {
it('getTodoList', async () => {
const mockData = [{ id: 1, name: 'zqj' }]
// @ts-ignore
axios.get.mockImplementationOnce(() => Promise.resolve({ data: mockData }))
const totoList = await getTodoList()
expect(totoList).toEqual(mockData)
})
})
经过这个几个例子,还学习了模拟 http 接口请求。
清除模拟
使用模拟函数后,需要清除模拟,否则会影响其他用例。
mockFunction.clearMock()
、 jest.clearAllMocks()
和 jest.resetAllMocks()
可清除模拟。
使用下面的配置,可在每个测试用例执行前清除模拟,不用手动清除。
module.exports = {
// 在某个测试用例执行之前清除模拟
clearMocks: true,
}
jest.mock 使用小结
jest.mock('path/to/file')
--- 模拟全局依赖jest.mock('path/to/file', factory)
--- 在测试文件中模拟模块jest.doMock('path/to/file', () => {})
--- 在某个用例里模拟模块,需要手动清除模拟- 模拟的模块在测试文件中不能省略导入语句
模拟定时器
模拟全局变量
如何模拟 fetch?
再谈模拟 http 接口
参考:
javascript-testing-rest-api-mock-service-worker
模拟 websocket
javascript-testing-mocking-websockets-mock-socket
jest.fn 的原理
模拟出来的函数有一个 mock
,一个对象,里面记录着函数的执行情况。
{
// 记录调用次数和参数
calls: [
[]
],
// this 对象
instances: [undefined],
// 调用顺序
invocationCallOrder: [1],
// 返回值
results: [{
type: 'return',
value: 'hello'
}],
lastCall: []
}
函数执行的完成的结果有三种:
- 显示地返回一个值;
- 隐式地返回
undefined
; - 抛出错误。
现在实现一个简易版的模拟函数 mockFn
function mockFn() {
const fn = () => {}
fn.mock = {
calls: [],
results: [],
instances: [],
}
// 1. mockFn 返回一个函数
// 2. 函数是一个可调用对象,可有自己得属性,所以可附上 mock 属性
return fn
}
参数情况
// 3. mockFn 有一个无操作函数作为参数
function mockFn(impl = () => {}) {
// 4. fn 接收不定参数
const fn = (...agrs) => {}
fn.mock = {
calls: [],
results: [],
instances: [],
}
return fn
}
如何记录 fn 的调用情况呢?在函数 fn 内部记录。
const fn = (...args) => {
fn.mock.calls.push(args)
fn.mock.instances.push(this)
try {
const value = impl.apply(this, args)
fn.mock.results.push({
type: 'return',
value
})
return value
} catch (error) {
fn.mock.results.push({
type: 'throw',
value: error
})
return error
}
}
完整的代码:
function mockFn(impl = function() {}) {
const fn = function(...args) {
fn.mock.calls.push(args)
fn.mock.instances.push(this)
try {
const value = impl.apply(this, args)
fn.mock.results.push({
type: 'return',
value
})
return value
} catch (error) {
fn.mock.results.push({
type: 'throw',
value: error
})
return error
}
}
fn.mock = {
calls: [],
results: [],
instances: [],
}
return fn
}
涉及到的关键知识:
- 函数是可调用的对象,能添加属性;
- 函数闭包;
- apply 指定 this。
测试一下看:
const f2 = mockFn(function(n, m) {
return n + m
})
const r1 = f2(1, 2)
const r2 = f2(0, 10)
console.log(r1, 'zqj log')
console.log(r2, 'zqj log')
console.log(f2.mock, 'zqj log')
经过测试,符合我们的预期。
参考
How to Write Functional Tests in React (Part 1)
Jest Spyon: All You Need To Know About This Function
Spying on Functions and Changing Implementation