Mock 函数

Mock Functions(模拟函数)也被称为“spies”(间谍),官方文档:Mock Functions

介绍

Mock Functions 的使用方法是抹除函数的实际实现,捕获对函数的调用(以及在这些调用中传递的参数),在使用 new 实例化时捕获构造函数的实例,并允许测试时配置返回值。

有两种方法可以 mock 函数:

  • 在测试代码中创建一个 mock 函数
  • 编写一个手动 mock 来覆盖模块依赖

Mock 函数的作用:

  1. 捕获函数的调用和返回结果,以及函数调用时的 this 指向
  2. 它可以让我们自由的设置返回结果
  3. 可以改变内部函数的实现

使用一个 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')
})

beforeEachafterEach 也可以写在测试用例后面,它们总会在测试执行前执行。

beforeEachafterEach 处理异步代码的方式与测试用例处理异步代码的方式一样——可以接收一个 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 记录数据。