Jest单元测试入门和实例

4,146 阅读12分钟

1. 为什么选择Jest?

Jest 是 Facebook 出品的一个测试框架,相对其他测试框架,其一大特点就是就是内置了常用的测试工具,比如自带断言Mock 功能测试覆盖率工具,实现了开箱即用

2. 使用

1. 如何安装

Jest 可以通过 npmyarn 进行安装。以 npm 为例,既可用 npm install -g jest 进行全局安装;也可以只局部安装、并在 package.json 中指定 test 脚本:

{
  "scripts": {
    "test": "jest"
  }
}

Jest 的测试脚本名形如 *.test.js,不论 Jest 是全局运行还是通过 npm run test 运行,它都会执行当前目录下所有的*.test.js*.spec.js 文件、完成测试。

2. 简单例子

先让我们在一个react工程目录中src下新建一个__tests__文件夹,在里面写我们的各种测试案例。

首先,创建一个sum.js文件:

function sum(a, b){
    return a + b;
}

function foo(){
  return 'hello world'
}

export { sum,foo };

然后在__tests__中,新建一个sum.test.js的文件:

import { sum,foo } from './sum.js';

describe('test testObject', () => {
  test('测试求和函数', () => {
      expect(sum(2, 2)).toBe(4);
  });

  test('测试foo函数', () => { // 也可以用it
      expect(foo()).toBe('hell world');
  });
})

test() 函数表示一个测试用例

describe() 函数表示一组用例

expect() 函数返回一个期望值对象,该对象提供了大量的工具方法来做结果判定

toBe() 函数表示断言方法

我们把 sum foo 置于 describe() 函数的处理回调函数中,就实现了对一组用例的测试。

在Jest官方文档中,it 是 test 的别名,二者可以通用

最后运行 yarn testnpm run test,将打印出下面的消息,表示测试通过了。

 PASS  __test__/sum.test.js
 PASS  __test__/foo.test.js
  √ 测试求和函数 (7ms)
  √ 测试foo函数 (1ms)

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.8s

3. 匹配器

1. 判断相等:

注意:toBe 使用 Object.is 来测试精确相等。 如果想要检查对象的值,请使用 toEqual 代替,它会递归判断对象的每一个字段。对数值来说,toBetoEqual 都可以使用。

类比 js 值类型和引用类型

test('2加2等于4', () => {
    expect(2+2).toBe(4);
});
// 测试对象相等
test('测试对象的值', () => {
    const data = {a: 1};
    expect(data).toEqual({a: 1});
});
// 测试不等,相反的值
test('2加2不等于1', () => {
    expect(2 + 2).not.toBe(1);
});

2. 判断真假、空值:

  • toBeNull 只匹配 null;
  • toBeUndefined 只匹配 undefined;
  • toBeDefined 与 toBeUndefined 相反;
  • toBeTruthy 匹配任何 if 语句为真;
  • toBeFalsy 匹配任何 if 语句为假;
test('null', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).not.toBeTruthy();
  expect(n).toBeFalsy();
});

3. 判断数字:

test('2加2', () => {
  const value = 2 + 2;
  expect(value).toBeGreaterThan(3); // 大于3
  expect(value).toBeGreaterThanOrEqual(4); // 大于或等于4
  expect(value).toBeLessThan(5); // 小于5
  expect(value).toBeLessThanOrEqual(4.5); // 小于或等于4.5
});

4. 判断符点数:

可使用 toBeCloseTo 来解决 JS 浮点精度带来的问题

test('两个浮点数字相加', () => {
  const value = 0.1 + 0.2;
  //expect(value).toBe(0.3);  这句会报错,因为浮点数有舍入误差
  expect(value).toBeCloseTo(0.3); // 这句可以运行
});

5. 判断字符串:toMatch()

test('there is no I in team', () => {
  expect('team').not.toMatch(/I/);
});

test('but there is a "stop" in Christoph', () => {
  expect('Christoph').toMatch(/stop/);
});

6. 判断数组或可迭代的对象:toContain()

const shoppingList = [
  'diapers',
  'kleenex',
  'beer',
];

test('the shopping list has beer on it', () => {
  expect(shoppingList).toContain('beer');
});

7. 判断异常:toThrow()

function compileAndroidCode() {
  throw new Error('这是一个错误消息');
}

test('compiling android goes as expected', () => {
  expect(compileAndroidCode).toThrow();

  // 可以匹配异常消息的内容,也可以用正则来匹配内容
  expect(compileAndroidCode).toThrow('这是一个错误消息');
  expect(compileAndroidCode).toThrow(/消息/);
});

Tips: 代码提示💡

expect 的工具方法太多记不住怎么办,在 ts 开发环境下,安装 jest@types/jest 包含的声明文件,通过声明文件可以获得jest的类型定义,也可以使用类型检查等特性,写代码的时候就会有提示。

npm i jest @types/jest

avatar

ps: ts是不是很香,提高了打工人的生产力😎

types 和 @types 是什么?

zhuanlan.zhihu.com/p/194196536

4. 异步测试

异步代码的测试,关键点在于告知测试框架测试何时完成,让其在恰当的时机进行断言。

1. Callbacks回调函数:done

当我们的test函数中出现了异步回调函数时,可以给test函数传入一个 done 参数,它是一个函数类型的参数。如果test函数传入了done,jest就会等到 done 被调用才会结束当前的test case,如果 done 没有被调用,则该test自动不通过测试。

it('Test async code with done', (done) => {
  setTimeout(() => {
    // expect something
    done();
  }, 500)
});

2. 返回Promise:expect.assertions(number)

  • assertions 断言

如果使用的是 promise,需要在测试中 返回 一个 promise,Jest 会自动等待 promise 被解析处理,如果 promise 被拒绝,那么测试失败。

例1:

test("Test async code with promise", () => {
  // 一个rejected状态的 Promise 不会让测试失败
  expect.assertions(1);
  return doAsync().then((data) => {
    expect(data).toBe('example');
  });
});

test("Test promise with an error", () => {
  // 一个fulfilled状态的 Promise 不会让测试失败
  expect.assertions(1);
  return doAsync().catch(e => {
    expect(e).toMatch('error')
  });
});

avatar

注意1:确保 返回promise,如果忽略掉 return,那么测试会在 promise 完成之前完成。

注意2:expect.assertions(number)验证在测试期间是否调用了一定数量的断言。

同时满足以上两个条件

函数 doAsync,该函数接收两个回调 callback1callback2,它将以未知顺序异步调用这两个回调。

例2:

test("doAsync calls both callbacks", () => {
  expect.assertions(2);
  function callback1(data) {
    expect(data).toBeTruthy();
  }
  function callback2(data) {
    expect(data).toBeTruthy();
  }
  doAsync(callback1, callback2);
});

使用 expect.assertions(2) 确保两个回调都实际被调用。

  • .resolves / .rejects Jest语法糖

例1中的代码用匹配符 resolves/rejects (这里有s,非Promise)可以改写为:

// 假设 doAsync() 返回一个promise,resolve的结果为字符串'example'
it('Test async code with promise', () => {
  expect.assertions(1);
  return expect(doAsync()).resolves.toBe('example');
  });
});

it('Test promise with an error', () => {
  expect.assertions(1);
  return expect(doAsync()).rejects.toMatch('error'));
});
  • async/await Promise语法糖

实际开发中,我们更常用的是用 async/await 来开发业务代码,上面例子的也可以 async/await 实现

// 假设 doAsync() 返回一个promise,resolve的结果为字符串'example'
it('Test async code with promise', async () => {
  expect.assertions(1);
  const data = await doAsync();
  expect(data).toBe('example');
  });
});

async/await 也可以和 resolves/rejects 一起使用:

// 假设 doAsync() 返回一个promise,resolve的结果为字符串'example'
it('Test async code with promise', async () => {
  expect.assertions(1);
  await expect(doAsync()).resolves.toBe('example');
  });
});

3. done 和 assertions 区别

done:异步回调确保测试

assertions:Promise确保测试

一般测试的时候,异步都是模拟 mock 出来的,要自己控制结束,而不是真正的异步。所以 expect.assertions 某些情况下无法替代 done

5. Mock函数

在项目中,一个模块的方法内常常会去调用另外一个模块的方法。在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。此时,使用Mock函数是十分有必要。

1. jest.fn()

jest.fn()是创建 Mock 函数最简单的方式,如果没有定义函数内部的实现,jest.fn() 会返回 undefined 作为返回值。

test('测试jest.fn()调用', () => {
  let mockFn = jest.fn();
  let result = mockFn(1, 2, 3);

  // 断言mockFn的执行后返回undefined
  expect(result).toBeUndefined();
  // 断言mockFn被调用
  expect(mockFn).toBeCalled();
  // 断言mockFn被调用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 断言mockFn传入的参数为1, 2, 3
  expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
})

jest.fn()所创建的Mock函数还可以设置返回值,定义内部实现返回Promise对象

test('测试jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 断言mockFn执行后返回值为default
  expect(mockFn()).toBe('default');
})

test('测试jest.fn()内部实现', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 * num2;
  })
  // 断言mockFn执行后返回100
  expect(mockFn(10, 10)).toBe(100);
})

test('测试jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let result = await mockFn();
  // 断言mockFn通过await关键字执行后返回值为default
  expect(result).toBe('default');
  // 断言mockFn调用后返回的是Promise对象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

🌰 实际使用:

ts 开发中,有些类型定义为必填项

onChange = (
  pagination: PaginationConfig,
  filters: Partial<Record<keyof T, string[]>>,
  sorter: SorterResult<T>,
  extra: TableCurrentDataSource<T>,
) => {}

假如 filters 是函数类型,为必填项,实际测试不想传入这个值,传参可以填 jest.fn() mock掉这个函数。

所有的 mock 函数都有一个特殊的 .mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  };
};

test('测试forEach函数', () => {
    const mockCallback = jest.fn(x => 42 + x);
    forEach([0, 1], mockCallback);
    // 此 mock 函数被调用了两次
    expect(mockCallback.mock.calls.length).toBe(2);
    // 第一次调用函数时的第一个参数是 0
    expect(mockCallback.mock.calls[0][0]).toBe(0);    
    // 第一次函数调用的返回值是 42
    expect(mockCallback.mock.results[0].value).toBe(42);
})

2. jest.mock()

通常情况下,我们需要调用api,发送ajax请求,从后台获取数据。但是我们在做前端测试的时候,并不需要去调用真实的接口,所以此时我们需要模拟 axios/fetch 模块,让它不必调用api也能测试我们的接口调用是否正确。

下面我们创建一个 events.js

// events.js
import fetch from './fetch';
 
export default {
  async getPostList() {
    return fetch.fetchPostsList(data => {
      console.log('fetchPostsList be called!');
      // do something
    });
  }
}

测试代码如下:

import events from '../src/events';
import fetch from '../src/fetch';
 
jest.mock('../src/fetch.js');
 
test('mock 整个 fetch.js模块', async () => {
  expect.assertions(2);
  await events.getPostList();
  expect(fetch.fetchPostsList).toHaveBeenCalled();
  expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});

在测试代码中我们使用了 jest.mock('axios') 去mock整个 fetch.js 模块。如果注释掉这行代码,执行测试脚本时会出现以下报错信息:

avatar

从这个报错中,我们可以总结出一个重要的结论:

注意: 在 jest 中如果想捕获函数的调用情况,则该函数必须被 mock 或者 spy

3. jest.spyOn()

jest.spyOn() 方法创建一个mock函数,并且可以正常执行被spy的函数。 jest.spyOn()jest.fn() 的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数。

import events from '../src/events';
import fetch from '../src/fetch';
 
test('使用jest.spyOn()监控fetch.fetchPostsList被正常调用', async() => {
  expect.assertions(2);
  const spyFn = jest.spyOn(fetch, 'fetchPostsList');
  await events.getPostList();
  expect(spyFn).toHaveBeenCalled();
  expect(spyFn).toHaveBeenCalledTimes(1);
})

运行之后,可以看到shell中的打印信息,说明通过jest.spyOn()fetchPostsList被正常的执行了。

avatar

🌰 在实际项目的单元测试中:

jest.fn() 常被用来进行某些有回调函数的测试;

jest.mock() 可以mock整个模块中的方法,当某个模块已经被单元测试100%覆盖时,使用jest.mock()去mock该模块,节约测试时间和测试的冗余度是十分必要;

jest.spyOn() 当需要测试某些必须被完整执行的方法时,常常需要使用;

这些都需要开发者根据实际的业务代码灵活选择。

6. Jest钩子函数

1. 执行顺序和作用域

钩子函数是指在某一时刻,jest会自动调用的函数。如下:

  • beforeAll:在所有测试用例执行之前执行
  • afterAll:等所有测试用例都执行之后执行 ,可以在测试用例执行结束时,做一些处理
  • beforeEach:每个测试用例执行前执行,可让每个测试用例中使用的变量互不影响,因为分别为每个测试用例实例化了一个对象
  • afterEach:每个测试用例执行结束后,做一些处理

注意:钩子函数的作用域为: 所在的 describe 分组;

import Counter from './Counter'

// 使用类中的方法,首先要实例化
let counter = null
beforeAll(() => {
	console.log('外部的 beforeAll 执行')
})

beforeEach(() => { 
	counter = new Counter()
	console.log('外部的 beforeEach 执行')
})

describe('Counter 的测试代码', () => {
	describe('Counter 中的加法测试代码', () => {
		beforeAll(() => {
			console.log('内部的 beforeAll 执行')
		})
		test('测试 Counter 中的 addOne 方法', () => {
			counter.addOne()
			expect(counter.number).toBe(1)
		})
		
		test('测试 Counter 中的 addTwo 方法', () => {
			counter.addTwo()
			expect(counter.number).toBe(2)
		})
	})
	
	describe('Counter 中的减法测试代码', () => {
		test('测试 Counter 中的 minusOne 方法', () => {
			counter.minusOne()
			expect(counter.number).toBe(-1)
		})
	
		test('测试 Counter 中的 minusTwo 方法', () => {
			counter.minusTwo()
			expect(counter.number).toBe(-2)
		})
	})	
})

打印结果:

avatar

🌰 实际使用:

import { shallow, mount } from '@vue/test-utils'
import Test2 from './Component'

describe('Test for Test2 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(Test2);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  it('returns the string in normal order if reversed property is not true', () => {
    wrapper.setProps({needReverse: false});
    wrapper.vm.inputValue = 'ok';
    expect(wrapper.vm.outputValue).toBe('ok')
  });
});

shallow / mount,创建一个包含被挂载和渲染的Vue组件的 Wrapper,每次执行测试语句都要 destroy() 一下, 这样每个test语句都是重新创建的独立 wrapper ,避免引起副作用。

比如在测试 FormModal 时, 如果modal没有 destroy,在修改情况下,下次代入的还是旧的 form 值。

ps: 在测试modal时,需要先 show 一下,否则是测不到的😂,和实际代码逻辑是一样的。

2. describe中的基础代码执行顺序

import Counter from './Counter'

// 使用类中的方法,首先要实例化
let counter = null
beforeEach(() => {
	counter = new Counter()
	console.log('外部的 beforeEach 执行')
})

describe('Counter 的测试代码', () => {
	console.log('describe 11111')
	describe('Counter 中的加法测试代码', () => {
		console.log('describe 22222')
		beforeAll(() => {
			console.log('内部的 beforeAll 执行')
		})
		test.only('测试 Counter 中的 addOne 方法', () => {
			counter.addOne()
			expect(counter.number).toBe(1)
		})
		
		test('测试 Counter 中的 addTwo 方法', () => {
			counter.addTwo()
			expect(counter.number).toBe(2)
		})
	})
	
	describe('Counter 中的减法测试代码', () => {
		console.log('describe 33333')
		test('测试 Counter 中的 minusOne 方法', () => {
			counter.minusOne()
			expect(counter.number).toBe(-1)
		})
	
		test('测试 Counter 中的 minusTwo 方法', () => {
			counter.minusTwo()
			expect(counter.number).toBe(-2)
		})
	})	
})

打印结果:

avatar

由打印结果可以看出,describe 中的基础代码并没有按照我们的意愿去执行,而是最先执行了,所以当我们在写测试代码的基础代码时,一定要在钩子函数内完成。

3. only和skip

test.only(name, fn)

describe.only(name, fn)

only 只对单个测试用例进行测试

test.skip(name, fn)

describe.skip(name, fn)

skip 跳过某个测试用例进行测试

7. 其他知识点

  • 写单元测试的时候,同时运行 --watch 命令,每次保存都会自动运行,查看当前test语句是否通过。

Vscode 也有Jest插件,可以debug,亲测不好用,也许是自己不熟悉🤔

npm run test page/Component/BatchUpload.test.ts --watch
  • 每写完一个测试文件, 都可以运行 --coverage 命令, 查看分支或者语句的覆盖率, 也可以定位到某个文件夹, 查看模块的覆盖率。
npm run test page/Component/BatchUpload.test.ts --coverage
  • 单元测试指标

% stmts 是语句覆盖率(statement coverage):是不是每个语句都执行了?

% Branch 分支覆盖率(branch coverage):是不是每个 if 代码块都执行了?

% Funcs 函数覆盖率(function coverage):是不是每个函数都调用了?

% Lines 行覆盖率(line coverage):是不是每一行都执行了?

avatar

8. Vue单元测试实例

🏰 传送门

Jest结合Vue-test-utils使用的初步实践

github: Vue-unit-test-with-jest

9. 小结

疑问:明知道sum函数返回a、b之和,为什么还要写测试语句呢 💬

这个和form表单的校验有点类似,有单元测试可以对代码逻辑进行回归验证。

而且在写测试的过程中, 可以发现自己程序的bug,会反向思考自己的代码逻辑,组件划分是否合理、逻辑是不是可以单独拿出来,这是一个相互促进的过程。

很多开源的组件库,项目都有相对应的单元测试,对于开发质量也是一个保证,别人在用的时候也会增强使用信心。