单元测试 - Jest 入门知识全覆盖 !

2,809 阅读25分钟

通过上一篇文章 前端自动化测试 - 入门篇,相信大家对与前端自动化测试有了一个初步的认识和了解,这一节,我们将深入了解其中的单元测试,以Jest框架为例,从理论到实践全面介绍单元测试(看完这篇,再也不用担心面试官问关于单元测试的问题啦)。

基本介绍和配置

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

Jest 适用但不局限于使用以下技术的项目:Babel,、TypeScript、 Node、 React、Angular、Vue 等。

Jest 主要特点:

  • 零配置
  • 自带断言
  • 而作为一个面向前端的测试框架, Jest 可以利用其特有的快照测试功能,通过比对 UI 代码生成的快照文件,实现对 React 等常见前端框架的自动测试。
  • 此外, Jest 的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,提升了测试速度。
  • 测试覆盖率
  • Mock 模拟

这些特点,大家简单了解即可,后续的实践会有一个更深入的介绍和使用。

项目初始化

  1. 创建项目
mkdir jest-demo
cd jest-demo
npm init
  1. 安装jest
npm i jest --save-dev
  1. 执行测试命令 在package.json中配置jest命令
// package.json
// ...
scripts: {
    "test": "jest --watchAll",
}

然后执行 npm run test 即可对所有以 *.test.js结尾的文件进行测试。

设置Jest配置

我们有两种方式去设置jest配置:

  1. 通过jest --init单独生成jest配置文件
  2. 通过 cli 命名行参数配置。

单独生成配置文件

jest --init

执行该命令,可以生成jest.config.js配置文件,我们可以对其中的一些属性进行全局修改。

使用 CLI选项

即除了可以通过配置文件去配置相关属性外,我们也可以直接在执行命令的时候,指定相关参数。例如:

npx jest 01/demo.test.js --watchAll // 测试指定文件

这里我们说明一下:-watchAll 属性

jest --watchAll // 直接监视所有文件
jest --watch // 需要和git配合使用,也就是说只会监视git中已修改且未添加到暂存区的文件,

运行结果如下:

image.png 除此之外,还要注意一下,上图中的一些额外的监视选项: f,o等。 其实就是为了更快捷的监听文件的变化,在实际开发中时候之后即可掌握。

使用ES6模块

默认情况下,在.test.js的测试文件中,只能通过commonJS规范去引入要测试的模块,如果我们想使用ES6模块,则需要额外做一些配置。

  1. 安装babel相关依赖
npm i babel-jest @babel/core @babel/preset-env --save-dev
  1. 添加babel.config.js
// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

然后,我们就可以在.test.js测试脚本中使用ES6模块啦。

Jest 结合 Babel 的运行原理:运行测试之前,结合 Babel,先把你的代码做一次转化,模块被转换为了 CommonJS,运行转换之后的测试用例代码。 具体流程如下:

  • npm run jest
  • jest
  • babel-jest
  • babel-core
  • babel 配置文件
  • 转换为 ES5
  • 执行转换之后的测试代码

初步体验

首先,我们通过一个简单例子直观的感受一下,如何去通过Jest写单元测试。

例如:我们定义了sum和subtract两个函数,如何给它们添加写测试用例呢?

// demo.js
function sum(a, b) {
  return a + b;
}
function subtract(a, b) {
  return a - b;
}

module.exports = {
  sum,
  subtract,
};
// demo.test.js
const { sum, subtract } = require("./demo");

test("sum: ", () => {
  expect(sum(1, 2)).toBe(3);
});

test("subtract: ", () => {
  expect(subtract(1, 2)).toBe(-1);
});

此时,要测试的函数以及测试脚本都已经写好,接下来,我们需要安装jest依赖。

npm i jest --save-dev

然后,配置script运行脚本

{
	 "scripts": {
    "test": "jest"
  },
  //...
}

执行 npm run test,此时jest就会自动去寻找当前项目下所有以.test.js结尾的文件,并且执行测试脚本。

注意:我们在测试脚本中并没有手动去引入jest,而是直接使用test,expect等方法,这是因为在执行jest命令时,会自动将这些方法注入到.test.js文件中,不需要我们手动引入

此时,可能会有疑问,不手动引入依赖包,那我们调用其API的智能提示不就没有啦,确实没有啦,这时,我们可以安装下面的依赖来解决智能提示的问题

npm i @types/jest --save-dev // 注意必须安装到项目根目录下。

全局API

jest对象

我们安装jest之后,执行 jest 命令,被执行的测试文件会自动创建jest全局对象。

jest 对象在每个测试文件的作用范围内. jest 对象中的方法帮助创建模型并让你控制Jest的整体行为。 也可以通过 import {jest} from '@jest/globals'引入。

jest对象内部属性,我们可以直接参考 官网-jest对象 ,这里暂时不展开说啦,后面我们会根据实际例子对其中一些常用属性详细说明。

test函数

test函数,是单元测试的核心,它可以帮助我们创建一个测试用例,而单元测试就是由一个个的测试用例所组成的。

test('对要测试的功能点说明', () => {
   // ...
})

expect函数

这里会有一个新的名词:断言,其实就是判断实际运行的结果是否符合我们预期的结果,expect函数,顾名思义期望的意思。既然是对已有代码进行测试,其实就是把实际运行的结果与我们期望的结果进行比较,如果一致,就说明测试通过。

expect(1).toBe(1); 

describe 函数

通过test函数可以创建一个个的测试用例,那当我们的测试用例越来越多的时候,就需要对测试用例进行分类整理,那这就是describe函数的作用。

describe('测试1', () => {
    test('测试用例1-1', () => {
        //...
    })
    test('测试用例1-2', () => {
        //...
    })
})

describe('测试2', () => {
    test('测试用例2-1', () => {
       //...
    })
    test('测试用例2-2', () => {
        //...
    })
})

在实际开发中,describe和test都是我们的必用函数。

生命周期钩子函数

顾名思义,就是在每个测试用例执行前后,我们会有一些钩子函数,从而用来做一些拦截性的工作。

beforeAll // 所有测试用例执行之前执行,只执行一次
beforeEach  // 每个测试用例执行之前都会执行,可能执行多次
afterEach // 每个测试用例执行之后都会执行,可能执行多次
afterAll // 所有测试用例执行之后执行,只执行一次。

除此,之外,这些钩子函数,也可以在describe函数之中使用。

// counter.js
export default class Counter {
  constructor() {
    this.number = 0;
  }
  addOne(){
    this.number += 1;
  }
  addTwo(){
    this.number += 2;
  }
  minusOne() {
    this.number -= 1;
  }
  minusTwo() {
    this.number -= 2;
  }
}
// test.counter.js
import Counter from './counter'
​
let counter = null;
beforeAll(() => {
  console.log('beforeAll')
})
afterAll(() => {
  console.log('afterAll');
})
​
beforeEach(() => {
  console.log('beforeEach')
  counter = new Counter();
})
afterEach(() => {
  console.log('afterEach')
})
​
describe('测试 add 相关代码', () => {
  beforeAll(() => {
    console.log('beforeAll add test')
  })
  afterAll(() => {
    console.log('afterAll add test');
  })
  beforeEach(() => {
    console.log('beforeEach add test')
  })
  afterEach(() => {
    console.log('afterEach add test')
  })
  test.only('测试 Counter 中的 addOne 方法', () => {
    console.log('测试 Counter 中的 addOne 方法')
    counter.addOne();
    expect(counter.number).toBe(1);
  })
  test('测试 Counter 中的 addTwo 方法', () => {
    console.log('测试 Counter 中的 addTwo 方法')
    counter.addTwo();
    expect(counter.number).toBe(2);
  })
})
​
describe('测试 minus 相关代码', () => {
  test('测试 Counter 中的 minusOne 方法', () => {
    console.log('测试 Counter 中的 minusOne 方法');
    counter.minusOne();
    expect(counter.number).toBe(-1);
  })
  test('测试 Counter 中的 minusTwo 方法', () => {
    console.log('测试 Counter 中的 minusTwo 方法');
    counter.minusTwo();
    expect(counter.number).toBe(-2);
  })
})
​

以上代码中第一个测试用例执行顺序如下:

image.png

这里要有两点需要额外注意:

  1. 如果我们暂时希望只执行当前测试用例,这时可以使用test.only()
  2. 生命周期的钩子函数是有作用域的,即只对生命周期钩子所在的describe内的测试用例有效。其他describe中的测试用例无法使用这些钩子函数。

常用matcher

即jest为我们提供了很多的匹配器API,可以帮助我们更快速的去断言程序的结果是否符合我们的预期。

这里,我们针对不同的数据类型做了分类整理:

布尔值

  • toBeNull
  • toBeDefined
  • toBeUndefined
  • toBeTruthy
  • toBeFalsy
test('null', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).not.toBeTruthy();
  expect(n).toBeFalsy();
});

字符串

  • toMatch()
test('测试字符串', () => {
  expect('hello world').toMatch(/world/);
});
​

数值

  • toBe
  • toEqual:等于
  • toBeGreaterThan:大于
  • toBeGreaterThanOrEqual:大于等于
  • toBeLessThan:小于
  • toBeLessThanOrEqual:小于等于
  • toBeCloseTo:浮点数比较
test('测试数值', () => {
  const value = 4;
  expect(value).toBeGreaterThan(3);
  expect(value).toBeGreaterThanOrEqual(4);
  expect(value).toBeLessThan(5);
  expect(value).toBeLessThanOrEqual(5);
  expect(value).toBe(4);
  expect(value).toEqual(4);
  expect(0.1 + 0.2).toBeCloseTo(0.3);
})

数组和集合

  • toContain
test('测试数组和集合', () => {
  const arr = ['kobe', 'james', 'durant']
  expect(arr).toContain('kobe');
  expect(new Set(arr)).toContain('kobe');
})

异常

  • toThrow
test('测试异常', () => {
  expect(() => fn()).toThrow();
  expect(() => fn()).toThrow(Error);
  expect(() => fn()).toThrow('this is a error');
  expect(() => fn()).toThrow(/error/);
})

以上是一些常用匹配器,更多的匹配器,可以参考:官网-匹配器

测试异步代码

之前在初步体验中,被测函数是一个简单的同步函数,有明确的输入和输出,这种我们写起来很简单,但是在平时开发中,很显然不可能这么简单,其中也肯定会遇到各种异步代码,例如:定时器,ajax请求等,

那如何测试异步代码呢?这里我们分为两种情况:

  1. callback形式的异步代码
  2. promise形式的异步代码

callback

  1. 首先我们来看一下,通过callback回调的方式执行的异步代码。
// fetchData.js
import axios from 'axios';
export const fetchData = (fn) => {
  return axios.get('http://www.dell-lee.com/react/api/demo1.json').then(response => {
    fn(response.data)
  })
}
​
// fetchData.test.js
import {fetchData} from './fetchData';
​
// 错误写法:这个测试用例不管接口有没有报错都会正常通过,因为传入的回调函数本身就没有执行。
test('fetchData 返回结果为 {success: true}', () => {
  fetchData((data) => {
    expect(data).toEqual({success: true});
  })
})
​
// 正确写法:需要在回调函数中手动调用done(),表示该回调函数执行以后,用例才算通过。
test('fetchData 返回结果为 {success: true}', (done) => {
  fetchData((data) => {
    expect(data).toEqual({success: true});
    done();
  })
})
​

总结: 核心就是需要给test的回调函数参数传入done, 然后手动执行。

Promise

  1. 我们在来看一下,通过promise的方式执行的异步代码
// fetchData.js
import axios from 'axios';
export const fetchData = (fn) => {
  return axios.get('http://www.dell-lee.com/react/api/demo1.json');
}
​
// 写法1: 在then或者catch回调函数中进行断言,注意一定要return。
test('fetchData 返回结果为 {success: true}', () => {
  return fetchData().then((resp) => {
    expect(resp.data).toEqual({success: true});
  })
})
test('fetchData 返回结果为 404', () => {
  return fetchData().catch((e) => {
    expect(e.toString().indexOf('404') > -1).toBe(true);
  })
})
​
// 写法2: 使用await/try...catch 
test('fetchData 返回结果为 {success: true}', async () => {
  const resp = await fetchData();
  expect(resp.data).toEqual({success: true});
})
test('fetchData 返回结果为 404', async () => {
  try {
    const resp = await fetchData();
  } catch(e) {
    expect(e.toString().indexOf('404') > -1).toBe(true);
  }
})
​
// 写法3: 使用jest自带属性:resolves和rejests
test('fetchData 返回结果为 {success: true}', () => {
  return expect(fetchData()).resolves.toMatchObject({
    data: {
      success: true
    }
  })
})
test('fetchData 返回结果为 404', () => {
  return expect(fetchData()).rejects.toThrow();
})
​

总结:

  1. 使用第一种写法,在then或者catch回调函数中进行断言时,一定要注意return。
  2. 使用第二种写法,正常情况很简洁,但是异常情况,需要手动进行try...catch。
  3. 使用第三种写法:写法比较简洁,但是需要熟练jest自带这些属性的用法。

Mock

Mock定时器

在实际写测试用例的时候,如果有一些比较耗时的代码,比如定时器,在对这些代码进行测试的时候,其实没必要按照实际设定的时间进行运行,因此,可以对定时器进行Mock。

如果想使用mock定时器,就必须要在文件头部声明:jest.useFakeTimers(),声明完之后,还需要我们去调用一些API去mock定时器已经快速执行完,如何mock定时器的执行呢?

  1. 使用 jest.runAllTimers 与 jest.runOnlyPendingTimers() 表示快速执行所有定时器或者处于消息队列中的定时器。
  2. 使用jest.advanceTimersByTime() 表示快进多长时间执行,比如定时器设置了3s后执行,我们就可以调用该方法,传入3s,就表示定时器立即执行。

jest.runAllTimers()

例如:测试代码如下:

// index.js
export const getData = (callback) => {
  setTimeout(() => {
    callback({
      name: 'kobe'
    })
  },3000)
}
export default getData;

于是,我们很容易写出如下的测试用例:

import {getData} from './index';
​
test('测试 getData', (done) => {
  getData((data) => {
    expect(data).toEqual({name: 'kobe'});
    done();
  })
})

以上写法是完全可以正常运行的,但是有一个问题就是它是按照实际定时器设置的时间去执行的,也就意味着我们需要等3000毫秒,因此也就出现了mock定时器,这样我们就可以不用按照实际定时器设置的时间执行啦。

那如何mock定时器呢?

import {getData} from './index';
​
jest.useFakeTimers();
​
test('测试 getData', () => {
  const fn = jest.fn();
  getData(fn);
  jest.runAllTimers();
  expect(fn).toHaveBeenCalledTimes(1);
})

注意:需要jest.useFakeTimers() 和 jest.runAllTimers()搭配使用,前者表示当前测试用例使用的都是mock定时器,后者表示执行所有的定时器。

jest.advanceTimersByTime() - 推荐

我们更推荐使用这个API去mock定时器。那我们基于上面的例子进一步体会一下:

// index.js 嵌套了两个定时器
export const getData = (callback) => {
  setTimeout(() => {
    callback();
    setTimeout(() => {
      callback();
    }, 3000)
  },3000)
}
export default getData;

我们 如果依然采用 jest.runAllTimers ,则会写出如下代码:

import {getData} from './index';
​
jest.useFakeTimers();
​
test('测试 getData', () => {
  const fn = jest.fn();
  getData(fn);
  jest.runAllTimers();
  expect(fn).toHaveBeenCalledTimes(2); // 很显然执行了两次
})

或者也可以采用 jest.runOnlyPendingTimers,代码如下:

import {getData} from './index';
​
jest.useFakeTimers();
​
test('测试 getData', () => {
  const fn = jest.fn();
  getData(fn);
  jest.runOnlyPendingTimers();
  expect(fn).toHaveBeenCalledTimes(1); // 很显然此时只执行了一次
})

那我们如果采用jest.advanceTimersByTime,代码如下:

import {getData} from './index';
​
jest.useFakeTimers();
​
test('测试 getData', () => {
  const fn = jest.fn();
  getData(fn);
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(1); // 快进3s,执行第一个定时器的回调
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(2); // 再快进3s,执行第二个定时器的回调
})

但是 jest.advanceTimersByTime() 有一个什么问题呢? 每次调用该API都是在上一步快进时间的基础上进行快进。多个测试用例之间也是这样,那如何解决测试用例之间的相互影响呢?

import {getData} from './index';
​
// 即每个测试用例都使用一个mock定时器,保证不会相互影响。
beforeEach(() => {
  jest.useFakeTimers();
})
​
test('测试 getData', () => {
  const fn = jest.fn();
  getData(fn);
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(1);
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(2);
})

Mock函数

在实际的开发中,被测试代码很可能不是一个简单的有明确输入输出关系的代码,那很可能是包含各种嵌套,回调,异步代码等,那我们在这些情况下,我们就需要通过mock函数的形式,其实本质上就是jest内部生成了一个函数,同时jest内部可以监控到这个函数的调用情况,从而帮助我们进行断言。

通过jest创建一个mock函数,该mock的函数体,返回值等我们都可以自由定义,当该mock函数被调用之后,我们也可以通过jest提供的内部属性,捕获到该mock函数被调用的次数,参数情况等。

基本使用

因此,mock函数通常可以实现以下这些功能:

  1. 捕获函数的调用情况,以及返回值,this指向以及调用顺序
  2. 它可以让我们自由设置 函数的返回值。
  3. 可以改变函数的内部实现

接下来,我们就通过实际代码学习一下:

  1. 捕获函数的调用情况,以及返回值,this指向以及调用顺序

    test.only('测试 mock 函数', () => {
      const func = jest.fn();
    ​
      func(123);
      console.log(func.mock);
      // 断言:函数是否被调用
      expect(func).toBeCalled();
      // 断言:函数被调用次数
      expect(func.mock.calls.length).toBe(1);
      // 断言:函数参数
      expect(func.mock.calls[0]).toEqual([123])
    })
    

    我们看一下func.mock的打印结果:

image.png

从图中我们可以看到:

  1. calls:可以看到函数被调用的次数,以及每次被调用时所传的参数,
  2. instances属性:可以看到函数被调用时,函数内部的this指向。
  3. invocationCallOrder:可以看到函数被调用的顺序,此处只调用了一次。
  4. results:可以看到函数每次调用的返回值是什么。
  5. 它可以让我们自由设置 函数的返回值。
test.only('测试 mock 函数', () => {
  const func = jest.fn();
  func.mockReturnValueOnce('kobe');

  func();
  expect(func.mock.results[0].value).toBe('kobe');
});

除此之外,还有几点细节要注意:

  1. 如果想在每次函数调用之后,设置不同的返回值,可以多次调用func.mockReturnValueOnce()。
  2. 如果每次调用返回相同的值,可以只调用一次func.mockReturnValue()
  3. 可以改变函数的内部实现
    test.only('测试 mock 函数', () => {
      const func = jest.fn();
      func.mockImplementationOnce(() => {
        return 'kobe';
      })
    ​
      func();
      expect(func.mock.results[0].value).toBe('kobe')
    });
    
    注意:同理,也可以通过 func.mockImplementationOnce 设置某一次调用对应的函数实现,可以针对不同的调用,设置不同的函数实现。

实战使用

上面,我们只是简单的通过jest.fn()直接创建了一个mock函数,并且进行调用,但是并没有真正的被测试代码,因此,本小节就是从一个实际例子出发,来进一步体会一下,如何去使用mock函数。

场景:我们要为数组的遍历函数:forEach写测试用例,该如何做呢?

// array.js
export const forEach = (arr, callback) =>  {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  };
}
// array.test.js
test.only('测试 forEach 函数', () => {
  const arr = [1, 2, 3, 4];
  const func = jest.fn();
  func.mockImplementation((item, index) => {
    return item + 1;
  })
​
  forEach(arr, func);
  // 断言:函数被调用次数
  expect(func.mock.calls.length).toBe(arr.length);
  // 断言:函数返回值
  expect(func.mock.results[0].value).toBe(2);
})

重点说明:mock函数使用方法其实很简单,记住这些常用的api即可,更重要的是我们要明白什么时候需要使用mock函数,当被测函数只是一个简单函数,有明确输入输出时,我们直接调用,然后断言其返回值即可,但是当被测函数内部比较复杂,可能涉及循环,异步逻辑,回调函数等场景时,通过就需要我们创建mock函数,并且将mock函数传入被测函数内部,调用被测函数,其内部也就会自动调用mock函数,从而我们就可以通过读取mock函数的mock属性,来获取该mock函数的调用次数,返回值等信息,从而判断被测函数是否正常。

Mock模块

Mock第三方模块

例如:我们发送请求时,通常会用到axios,但是我们又不想走真实的请求,所以就可以mock一个axios,然后就可以自由设置请求的返回值是什么。

// index.js
import axios from "axios"export const getData = () => {
  return axios.get('http://www.dell-lee.com/react/api/demo1.json').then(resp => resp.data);
}
// index.test.js
import axios from 'axios';
import {getData} from './index';
​
jest.mock('axios');
​
test('测试 getData 方法', async () => {
  axios.get.mockResolvedValue({
    data: {
      name: 'kobe'
    }
  })
  const data = await getData();
  expect(data).toEqual({name: 'kobe'});
})
​

mock模块部分实现 - 写法1

即一个模块中,可能有很多个方法,有同步方法,有异步方法,同步方法我们一般不需要模拟,但是异步方法,我们一般就需要一个对应的模拟方法,从而保证测试代码执行的之后,走的是我们的模拟方法,并不需要真正发起请求。

// index.js
import axios from "axios"export const getData = () => {
  return axios.get('http://www.dell-lee.com/react/api/demo.json').then(resp => resp.data);
}
​
export const sum = (a, b) => {
  return a + b;
}

该模块中,有两个方法,由于getData需要发起异步请求,而我们又不希望测试代码中就要发送真实的模拟请求,所以需要创建一个对应的mock函数,而sum方法比较简单,一个同步函数,直接引入测试即可。

注意:如果我们希望对某个模块进行mock,则可以在其同级目录下创建一个__mocks__文件夹,并且在改文件夹下创建同名的文件。

// __mocks__/index.js
export const getData = () => {
  return new Promise((resolve, reject) => {
    resolve({
      name: 'james'
    })
  })
}

接下来,我们就要写测试用例啦,由于我们要对index.js模块进行mock,所以需要手动调用jest.mock(./index),表示测试用例执行时走的是mock的函数。

​
jest.mock('./index')
import {getData, sum} from "./index";
​
test('测试 getData 方法', async () => {
  const res = await getData();
  expect(res).toEqual({
    name: 'james'
  })
})
​
// 此时该用例无法通过,因为走的是mock模块,而mock模块中并没有sum函数。
test('测试 sum 方法', async () => {
  expect(sum(1, 2)).toBe(3);
})

以上代码,由于我们统一走的都是mock模块,但是我们在测试sum函数时,又希望走真实模块,所以以上代码中的第二个测试用例是通过不了的,需要进行如下修改:

jest.mock('./index')
import { getData} from "./index";
const {sum} = jest.requireActual('./index.js') // 关键点test('测试 getData 方法', async () => {
  const res = await getData();
  expect(res).toEqual({
    name: 'james'
  })
})
​
test('测试 sum 方法', async () => {
  expect(sum(1, 2)).toBe(3);
})

mock模块部分实现 - 写法2

写法1中,我们在被测模块的同级目录下,创建了一个__mocks__文件夹,并且在该文件下创建了同名的mock模块。当然,其实我们也可以直接在测试文件中去mock模块中的部分方法。

jest.mock('./index', () => {
  return {
    getData: jest.fn(() => {
      return new Promise((resolve, reject) => {
        resolve({
          name: 'james'
        })
      })
    })
  }
})
​
import { getData} from "./index";
const {sum} = jest.requireActual('./index.js')
​
test('测试 getData 方法', async () => {
  const res = await getData();
  expect(res).toEqual({
    name: 'james'
  })
})
​
test('测试 sum 方法', async () => {
  expect(sum(1, 2)).toBe(3);
})

以上写法,可以其实可以进一步优化一下

jest.mock('./index', () => {
  // 其实就是先引入全部真实模块中的全部方法,然后解构,需要进行mock的函数直接覆盖即可。
  const originalModule = jest.requireActual('./index');
  return {
    ...originalModule,
    getData: jest.fn(() => {
      return new Promise((resolve, reject) => {
        resolve({
          name: 'james'
        })
      })
    })
  }
})
​
import { getData} from "./index";
​
test('测试 getData 方法', async () => {
  const res = await getData();
  expect(res).toEqual({
    name: 'james'
  })
})
​
test('测试 sum 方法', async () => {
  expect(sum(1, 2)).toBe(3);
})

Mock 类

例如:我们有一个Util类,如下:

// util.js
class Util {
  a () {}
  b() {}
}
export default Util

当然,它也有对应的一个测试文件。

// util.test.js
import Util from './util'
​
let util = null;
beforeAll(() => {
  util = new Util();
})
test('测试 Util a方法', () => {
  expect(util.a()).toBeUndefined();
})
test('测试 Util b方法', () => {
  expect(util.b()).toBeUndefined();
})

很显然,如果我们想对一个类中的方法进行测试,其实很简单,但是上面的代码不是我们这里要说的重点,我们要说的场景是:当其他文件中引用了Util中的方法时,我们该如何对其进行测试?

例如:

// demo.js
import Util from './util'export const demoFunction = () => {
  const util = new Util();
  util.a();
  util.b();
}

我们想要对demoFunction进行测试,那如何写测试用例呢?写之前,我们要明白:对demoFunction方法进行测试,虽然它内部引用了Util类相关的方法,但是我们并不需要对Util中的方法再进行测试,因为我们在util.test.js中已经对Util类中的方法进行了测试,此处demoFunction我们只需要判断Util中的方法是否被调用即可。

因此:我们就可以Mock一个类,从而保证Mock类中的方法被调用了即可。 实现如下:

jest.mock('./util.js') //关键点
import Util from './util'
import {demoFunction} from './demo'
​
test('测试 demoFunction', () => {
  demoFunction();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].a).toHaveBeenCalled();
  expect(Util.mock.instances[0].b).toHaveBeenCalled();
})

说明:当我们声明了jest.mock('./util.js') 之后,jest会自动将导入的Util变成一个mock函数,并且其内部的方法也都会自动变成mock函数,从而,我们就可以直接使用mock函数的特性,来判断函数是否被调用。

我们可以打印一下:Util.mock, 输入结果如下:

image.png 说明:其实我们之前学到的mock第三方模块,例如:axios,其实本质上就是mock了一个类,然后我们就可以调用mock类内部的方法。再通过代码来体会一下:

jest.mock('axios')
import axios from 'axios';
​
test('测试 request方法', () => {
  console.log(axios.get.mockReturnValue({name: 'kobe'}));
})

同时,通过前面的学习,我们也可以手动创建__mock__文件夹,以及同名的文件,去对Util中的方法进行自定义的mock。

因此,对类进行mock其实是四种方式:
  1. 通过jest.mock('./utils.js') 自动模拟
  2. 通过创建__mocks__ 同名文件,手动模拟
  3. 也可以不单独创建文件,直接在jest.mock(',/util.js', () => {}) 即第二个参数中,手动返回mock函数。
  4. 当然,我们某个测试用例中,单独使用mockImplementation 模拟某个函数的实现。

Dom测试

在jest内部集成了Jsdom依赖包,所有我们也可以直接使用Jsdom相关API去操作dom。

我们来实际体验一下:

function renderHtml() {
  const div = document.createElement("div");
  div.innerHTML = "<h1>Hello World</h1>";
  document.body.appendChild(div);
}
​
test("DOM Test:", () => {
  renderHtml();
  expect(document.querySelector("h1").innerHTML).toBe("Hello World");
});

快照测试

顾名思义,当我们的组件已经完成或者不希望更改时,我们可以将写好的组件或者dom结构生成一份快照,其实就是一个html字符串,之后如果无意间修改了dom结构,这时快照测试,就会提示我们两次生成的快照不一致,从而进一步判断以哪个为准。

我们来体验一下:

function renderHtml() {
  const div = document.createElement("div");
  div.innerHTML = "<h1>Hello World1</h1>";
  document.body.appendChild(div);
}
​
test("DOM Test:", () => {
  renderHtml();
  expect(document.querySelector("h1").innerHTML).toBe("Hello World");
});
​
test("Snapshot Test:", () => {
  expect(document.body).toMatchSnapshot(); // 首次执行会生成快照文件。
});

image.png 之后,如果我们想要更新快照文件,当然也是可以实现的,执行以下命令即可

jest --updateSnapshot

测试覆盖率

image.png

测试覆盖率就是执行过的代码占总代码的比例,比如执行了多少行(Line),执行了多少个分支(Branch),执行了多少个函数(Function),执行了多少条语句(Statement)。因此,覆盖率主要分为以下几种:

  1. 行覆盖率 Lines
  2. 分支覆盖率 Branch
  3. 函数覆盖率 Funcs
  4. 语句覆盖率 Stmts

实战案例

例如:对一个Stack类进行单元测试。

被测代码

// stack.js
export default class Stack {
  constructor () {
      this.stack = [];
  }
  push (...element) {
    this.stack.push(...element);
  }
  pop () {
      if (this.isEmpty()) {
          return undefined;
      }
      return this.stack.pop();
  }
  peek () {
      if (this.isEmpty()) {
          return undefined;
      }
      return this.stack[this.stack.length - 1]
  }
  isEmpty () {
      return this.stack.length === 0;
  }
  clear () {
      this.stack = [];
  }
  size () {
      return this.stack.length;
  }
}

bad case

于是,我们很容易写出以下这样的单元测试:

// stack.test.js
test('test push', () => {
  let stack = new Stack();
  stack.push(1);
  stack.push(2);
  expect(stack.size()).toBe(2);
});
​
test('test pop', () => {
  let stack = new Stack();
  expect(stack.pop()).toBeUndefined();
​
  stack.push(1);
  stack.push(2);
  expect(stack.pop()).toBe(2);
  expect(stack.pop()).toBe(1);
  expect(stack.pop()).toBeUndefined();
});
​
test('test peek', () => {
  let stack = new Stack();
  expect(stack.peek()).toBeUndefined();
  stack.push(1);
  stack.push(2);
  expect(stack.peek()).toBe(2);
});
​
test('test isEmpty', () => {
  let stack = new Stack();
  expect(stack.isEmpty()).toBe(true);
  stack.push(1);
  expect(stack.isEmpty()).toBe(false);
});
​
test('test clear', () => {
  let stack = new Stack();
  stack.push(1);
  expect(stack.size()).toBe(1);
  stack.clear();
  expect(stack.size()).toBe(0);
});
​
test('test size', () => {
  let stack = new Stack();
  stack.push(1);
  expect(stack.size()).toBe(1);
});

但是呢,以上代码看着好像对每个方法都写了测试用例,测试覆盖率达到了100%,但其实上面的测试用例存在大量问题:

  1. 用例结构要分类,比如同一个方法的测试用例,我们可以用describle包围起来,这样整体结构更清晰
  2. 要合理进行复用,以上代码每个测试用例中都创建了stack实例,其实没必要,可以复用stack实例,并且要巧用生命周期方法。
  3. 按需要定制通用部分,即如果多个测试用例中有一些可以复用的逻辑,我们都可以抽象出来。
  4. 面向意图,而不是面向对象。

good case

所有的测试用例都是面向意图,而不是面向对象。而我们上面的测试代码正是犯了这个错误。我们只是简单为Stack中的每个方法添加了一个测试用例,但其实很多方法都是联动的,所以没必要从方法的数量维度去设计测试用例,而是要从意图,或者从实际使用的角度去设计测试用例。

并且,每个每个方法随着参数的不同,返回值的不同等,都会有很多种情况,那么这个时候,我们要为每一种可能发生的情况都要设计对应的测试用例。

// stack.test.js
describe('init stack', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
  });
  test('default length', () => {
    expect(stack.items.length).toBe(0);
  });
});
​
describe('push method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
  });
  test('when push single,size increase 1', () => {
    stack.push(1);
    expect(stack.items.length).toBe(1);
  });
  test('when push multiple, size increase multiple', () => {
    stack.push(1, 2, 3, 4);
    expect(stack.items.length).toBe(4);
  });
  test('when push, get correct last item', () => {
    stack.push(1);
    expect(stack.items).toEqual([1]);
  });
});
​
describe('pop method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
    stack.push(1, 2, 3);
  });
  test('when pop, size decrease 1', () => {
    expect(stack.items.length).toBe(3);
    stack.pop();
    expect(stack.items.length).toBe(2);
  });
  test('when pop, return last item', () => {
    expect(stack.pop()).toBe(3);
  });
  test('when pop and stack is empty, return undefined', () => {
    stack.pop();
    stack.pop();
    stack.pop();
    expect(stack.pop()).toBeUndefined();
  });
});
​
describe('peek method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
    stack.push(1, 2, 3);
  });
  test('when stack is not empty, return last item', () => {
    expect(stack.peek()).toBe(3);
  });
  test('when stack is empty, return undefined', () => {
    stack.clear();
    expect(stack.peek()).toBeUndefined();
  });
});
​
describe('clear method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
    stack.push(1, 2, 3);
  });
  test('when clear, size is 0', () => {
    stack.clear();
    expect(stack.items.length).toBe(0);
  });
});
​
describe('size method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
    stack.push(1, 2, 3);
  });
  test('return correct size', () => {
    expect(stack.size()).toBe(3);
  });
});
​
describe('isEmpty method', () => {
  let stack;
  beforeEach(() => {
    stack = new Stack();
  });
  test('when stack is empty, return true', () => {
    expect(stack.isEmpty()).toBe(true);
  });
  test('when stack is empty, return false', () => {
    stack.push(1, 2, 3);
    expect(stack.isEmpty()).toBe(false);
  });
});

说明:

  1. 我们并不是只测试Stack的方法,同时要结合其实际使用,例如Stack的初始化操作等,我们也进行了单元测试。
  2. Push, pop等方法,我们都针对不同情况下的参数,以及返回值做了相应的测试用例。

case总结

通过这个简单case,我们可以清晰的感受如何去更好的写测试代码,通常要注意以下点:

  1. 要善用describe,把统一类型的测试用例组织起来,这样整体结构会更加清晰。
  2. 要善用生命周期钩子函数,我们通常可以在这些钩子函数中做一些可复用的操作,例如:数据的初始化,销毁等。
  3. 测试用例之间如果有公共的逻辑,我们也可以抽象成一个单独的方法。
  4. 测试用例的编写要从其实际使用情况出发,而不是单纯的面向对象去写测试用例。

总结

写单元测试,和我们平时写业务代码,所用的知识点其实没有本质上的区别,包括Jest内部提供的这些API,其实通过我们前面的介绍,看看官网,我们基本都可以掌握,个人感觉入门最麻烦的一点是思维方式的转变,尤其是Mock这块,我们需要知道什么时候进行Mock,小伙伴儿看完这节内容以后,再实际自己写代码体会体会,相信可以很快对单元测试有一个基本的认识和应用啦。

写作不易,如果觉得还不错的话,欢迎点赞和关注哦 😊😊😊