Mock 函数
Mock Functions(模拟函数)也被称为“spies”(间谍),官方文档:Mock Functions
介绍
Mock Functions 的使用方法是抹除函数的实际实现,捕获对函数的调用(以及在这些调用中传递的参数),在使用 new
实例化时捕获构造函数的实例,并允许测试时配置返回值。
有两种方法可以 mock 函数:
- 在测试代码中创建一个 mock 函数
- 编写一个手动 mock 来覆盖模块依赖
Mock 函数的作用:
- 捕获函数的调用和返回结果,以及函数调用时的 this 指向
- 它可以让我们自由的设置返回结果
- 可以改变内部函数的实现
使用一个 mock function
假设正在测试一个函数 forEach
的实现,该函数为提供的数组中的每项调用回调:
function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index], index)
}
}
我们发现,用常规的测试方法很难对该函数进行一些测试,如回调是否被调用,回调调用次数,每次回调传入的值,回调返回的结果等。
为了测试这个函数,可以使用一个 mock 函数,并检查 mock 的状态,以确保按预期调用回调。
test('Mock Functions', () => {
const items = [1, 2, 3]
// jest.fn() 创建一个空的 mock 函数
// 如果传递一个函数,则可以创建自定义实现的 mock
// const mockFn = jest.fn().mockImplementation((value, index) => {
// return value * 10
// })
// 传入 implementation(实现)可以简写为:
const mockFn = jest.fn((value, index) => {
return value * 10
})
// 将 mock 函数作为回调参数传入
forEach(items, mockFn)
// 打印 .mock 属性
console.log(mockFn.mock)
})
mock 函数的 .mock
属性存储了本身被调用的一些信息,打印结果:
{
# 每次调用接收的参数
calls: [ [ 1, 0 ], [ 2, 1 ], [ 3, 2 ] ],
# 函数被实例化的结果
instances: [ undefined, undefined, undefined ],
invocationCallOrder: [ 1, 2, 3 ],
# 每次调用返回的结果
results: [
{ type: 'return', value: 10 },
{ type: 'return', value: 20 },
{ type: 'return', value: 30 }
],
# 最后一次被调用接收的参数
lastCall: [ 3, 2 ]
}
可以使用 .mock
属性中的信息进行断言判断:
// 断言 mock 函数被调用了3次
expect(mockFn.mock.calls.length).toBe(3)
// 断言第一次调用接收的第一个参数是1
expect(mockFn.mock.calls[0][0]).toBe(1)
模拟实例
instances
一般是 mock 函数作为构造函数时使用,返回实例化的对象:
test('mock function instances', () => {
// 创建一个空函数,类似 function() {}
const mockFn = jest.fn()
// 实例化 a
const a = new mockFn()
// 创建 b 对象
const b = {}
// 创建一个相同实现的函数
// this (实例)指向 b
const bound = mockFn.bind(b)
// 调用 bound() 执行实例化
bound()
console.log(mockFn.mock.instances[0] === a) // true
console.log(mockFn.mock.instances[1] === b) // true
})
模拟返回值
mock 函数可以在测试期间将测试值注入代码:
const items = [1, 2, 3, 4]
const mockFn = jest.fn((value, index) => {
return value * 10
})
// 指定调用所有 mock 函数的返回值
mockFn.mockReturnValue(100)
forEach(items, mockFn)
console.log(mockFn.mock.results)
// [
// { type: 'return', value: 100 },
// { type: 'return', value: 100 },
// { type: 'return', value: 100 },
// { type: 'return', value: 100 }
// ]
也可指定单次调用的返回值:
const items = [1, 2, 3, 4]
const mockFn = jest.fn((value, index) => {
return value * 10
})
// 指定调用所有 mock 函数的返回值
mockFn.mockReturnValue(100)
// 指定依次调用一次 mock 函数的返回值
// 如果使用了 mockReturnValue,则将其作为默认的返回值
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second').mockReturnValue('default')
// 或在前面调用,结果一样
// mockFn.mockReturnValue('default').mockReturnValueOnce('first').mockReturnValueOnce('second')
forEach(items, mockFn)
console.log(mockFn.mock.results)
// [
// { type: 'return', value: 'first' },
// { type: 'return', value: 'second' },
// { type: 'return', value: 'default' },
// { type: 'return', value: 'default' }
// ]
使用模拟函数的返回值,有助于避免需要复杂的逻辑创建代表真实组件的行为,有利于在使用之前将值直接注入测试。
模拟模块
假设我们有一个使用 axios 请求 API 的方法:
// user.js
import axios from 'axios'
export const getAllUsers = () => {
return axios.get('/users.json').then(res => res.data)
}
为了测试这个方法,只能等待这个异步请求完成,并且不能使用 Timers Mocks 加快等待时间。
// user.test.js
import { getAllUsers } from './user'
test('should fetch users', () => {
return getAllUsers().then(data => expect(data).toEqual(users))
})
但是我们可以使用 jest.mock(...)
方法自动模拟 axios
模块。
一旦模拟了模块,它的方法成员就会被拦截代理访问。
这样,我们可以为 axios
模块的 .get
方法提供一个 mockResolvedValue
,返回我们期望测试使用的数据。
实际上,我们所做的就是希望 axios.get('/user.json')
返回一个假的响应。
// user.test.js
import axios from 'axios'
import { getAllUsers } from './user'
// 模拟模块
// 注意:这里传入的是模块名,而不是导入后的自定义名称
jest.mock('axios')
test('should fetch users', () => {
const users = [{ name: 'Bob' }]
const res = { data: users }
// 拦截 get 方法,模拟实现,响应自定义的测试数据
axios.get.mockResolvedValue(res)
// mockResolvedValue 是传入返回 resolved Promise 实现的简写:
// axios.get.mockImplementation(() => Promise.resolve(res))
return getAllUsers().then(data => expect(data).toEqual(users))
})
模拟实现
从上面的示例中,我们已经尝试了,可以模拟一个函数的具体实现,来减少耗时或逻辑,从而提高测试速度。
例如有一个 foo
模块:
// foo.js
export default function() {
// some implementation
}
可以通过 jest.mock('./foo')
模拟这个模块:
// foo.test.js
// 模拟模块写在导入模块前后都可以
jest.mock('./foo')
import foo from './foo'
// 模拟具体实现
foo.mockImplementation(() => 42)
test('test foo implementation', () => {
expect(foo()).toBe(42)
})
如果想要分别模拟函数多次调用的不同实现,可以使用 mockImplementationOnce
:
// foo.test.js
// 模拟模块写在导入模块前后都可以
jest.mock('./foo')
import foo from './foo'
// 模拟具体实现
// foo.mockImplementation(() => 42)
foo
.mockImplementationOnce(() => true)
.mockImplementationOnce(() => false)
test('test foo implementation', () => {
// expect(foo()).toBe(42)
expect(foo()).toBe(true)
expect(foo()).toBe(false)
// 当使用 mockImplementationOnce 定义的实现用完后,则使用默认实现:mockImplementation 定义的实现 或 原始实现
expect(foo()).toBeUndefined()
})
模拟函数的名称
您可以选择提供模拟函数的名称,该名称将在测试函数的错误输出中显示,而不是 jest.fn
:
test('mock unnamed', () => {
const mockFn = jest.fn()
expect(mockFn).toHaveBeenCalled()
// 输出 expect(jest.fn()).toHaveBeenCalled()
})
test('mock named', () => {
const mockFn = jest.fn().mockName('mockedFunction')
expect(mockFn).toHaveBeenCalled()
// 输出 expect(mockedFunction).toHaveBeenCalled()
})
自定义匹配器
为了减少声明如何调用模拟函数的要求,Jest 添加了一些自定义匹配器函数:
// 模拟函数至少被调用了1次
expect(mockFunc).toHaveBeenCalled();
// 模拟函数使用指定的参数至少被调用了1次
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);
// 模拟函数最后一次调用使用了指定的参数
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);
// 模拟函数的快照测试
expect(mockFunc).toMatchSnapshot();
上面的匹配器都是用于检查 .mock
属性语法糖,你也可以手动完成:
// 模拟函数至少被调用了1次
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);
// 模拟函数使用指定的参数至少被调用了1次
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);
// 模拟函数最后一次调用使用了指定的参数
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
arg1,
arg2,
]);
// 最后一次调用模拟函数的第一个参数是 `42`
// (请注意:对于这个特定的断言,没有对应的语法糖)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);
// 快照将检查模拟的调用次数是否相同,同样的顺序,同样的参数,同样的名称。
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.getMockName()).toBe('a mock name');
钩子函数
官方文档:Setup and Teardown
分别设置每个测试
如果一些工作需要在许多测试中重复进行,可以在每次测试前或后使用 beforeEach/afterEach
。
例如在某个测试中修改对象的属性,影响到了其他测试:
const user = {
foo: 'bar'
}
test('test 1', () => {
user.foo = 'baz'
expect(user.foo).toBe('baz')
})
test('test 2', () => {
// user.foo 已被改变,这里将会影响
expect(user.foo).toBe('bar')
})
可以在每个测试前初始化这个对象的值:
const data = {
foo: 'bar'
}
let user = null
test('test 1', () => {
user = Object.assign({}, data)
user.foo = 'baz'
expect(user.foo).toBe('baz')
})
test('test 2', () => {
user = Object.assign({}, data)
expect(user.foo).toBe('bar')
})
可以使用生命周期钩子优化这些重复操作:
const data = {
foo: 'bar'
}
let user = null
// 运行每个测试用例之前执行
beforeEach(() => {
user = Object.assign({}, data)
})
test('test 1', () => {
user.foo = 'baz'
expect(user.foo).toBe('baz')
})
test('test 2', () => {
expect(user.foo).toBe('bar')
})
beforeEach
和 afterEach
也可以写在测试用例后面,它们总会在测试执行前执行。
beforeEach
和 afterEach
处理异步代码的方式与测试用例处理异步代码的方式一样——可以接收一个 done
参数,也可以返回一个 Promise。
一次性设置全部测试
在某些情况下,只需要在文件开头仅进行一次设置。当设置是异步的时候,这可能会特别麻烦,因此不能以内联的方式进行,可以使用 beforeAll/afterAll
钩子处理这种情况。
例如:
// 在运行全部测试前初始化数据库访问,以在每个测试中重用数据库
beforeAll(() => {
return initializeCityDatabase();
});
// 在全部测试完成后,关闭数据库连接
afterAll(() => {
return clearCityDatabase();
});
test('city database has Vienna', () => {
expect(isCity('Vienna')).toBeTruthy();
});
test('city database has San Juan', () => {
expect(isCity('San Juan')).toBeTruthy();
});
同样的,beforeAll/afterAll
代码的位置并不影响它们执行的时机。
钩子函数作用域
默认情况下,钩子函数应用于文件中的每个测试,还可以使用describe
块将测试分组。当钩子函数位于 describe
块内时,它们仅适用于该块内的测试。
请注意,顶层的钩子函数在 describe
块中的钩子函数之前(before*
)或之后(after*
)执行,可以执行下面的代码查看所有钩子的执行顺序:
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
beforeAll(() => console.log('2 - beforeAll'));
afterAll(() => console.log('2 - afterAll'));
beforeEach(() => console.log('2 - beforeEach'));
afterEach(() => console.log('2 - afterEach'));
test('', () => console.log('2 - test'));
});
// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll
describe 和 test 块的执行顺序
Jest 在执行任何实际测试之前,先执行测试文件中的所有 describe
处理程序。
这也是在 before*
和 after*
内部(而不是在 describe
块内部)进行设置和注销的另一个原因。
一旦 describe
块执行完成,默认情况下 Jest 会按照收集阶段获得的顺序串行运行所有测试,在等待每个测试完成和整理好之后再继续。
请查看下面的打印顺序:
describe('outer', () => {
console.log('describe outer-a');
describe('describe inner 1', () => {
console.log('describe inner 1');
test('test 1', () => {
console.log('test for describe inner 1');
expect(true).toEqual(true);
});
});
console.log('describe outer-b');
test('test 1', () => {
console.log('test for describe outer');
expect(true).toEqual(true);
});
describe('describe inner 2', () => {
console.log('describe inner 2');
test('test for describe inner 2', () => {
console.log('test for describe inner 2');
expect(false).toEqual(false);
});
});
console.log('describe outer-c');
});
// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test for describe inner 1
// test for describe outer
// test for describe inner 2
使用建议
如果一个测试失败了,首先要检查的事情之一应该时当它是唯一运行的测试时,该测试是否失败,可以临时使用 test.only
代替 test
告知 Jest 仅运行这一个测试。
如果有一个测试,当它作为一个更大的套件的一部分运行时经常失败,但当你单独运行它时却没有失败,那么很有可能来自领域给测试的某个东西正在干扰这个测试。通常可以通过清除来自 beforeEach
的共享状态来解决这个问题。如果不确定是否正在修改某些共享状态,也可以尝试使用 beforeEach
记录数据。