单元测试框架Jest

1,948 阅读10分钟

Jest是Facebook开发的一个对javascript进行单元测试的工具,之前仅在其内部使用,后开源,并且是在Jasmine测试框架上演变开发而来,使用了我们熟知的**expect(value).toBe(other)**这种断言格式。

为什么要单元测试

官方文档中给出了非常清楚的说法:

  • 提供描述组件行为的文档
  • 节省手动测试的时间
  • 减少研发新特性时产生的 bug
  • 改进设计
  • 促进重构

自动化测试使得大团队中的开发者可以维护复杂的基础代码。

单元测试简介

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。

简单来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

对于开发活动中的各种测试,上图是一种最常见的划分方法:从下至上依次为 单元测试->集成测试->端到端测试 ,随着其集成度的递增,对应的自动化程度递减。

端到端(在浏览器等真实场景中走通功能而把程序当成黑盒子的测试)与集成测试(集合多个测试过的单元一起测试)的反馈与修复的周期比较长、运行速度慢,测试运行不稳定,由于很多时候还要靠人工手动进行,维护成本也很高。而单元测试只针对具体一个方法或API,定位准确,采用 mock 机制,运行速度非常快(毫秒级),又是开发人员在本地执行,反馈修复及时,成本较低。

我们把绝大部分能在单元测试里覆盖的用例都放在单元测试覆盖,只有单元测试测不了的,才会通过端到端与集成测试来覆盖。

相关概念ref
实例入门 Vue.js 单元测试

配置及常用匹配器

配置参考文档,配置完毕后就可以开始愉快的玩耍了
四个基础单词
编写单元测试的语法通常非常简单;对于jest来说,由于其内部使用了 Jasmine 2 来进行测试,故其用例语法与 Jasmine 相同。 实际上,只要先记这住四个单词,就足以应付大多数测试情况了:

  • describe: 定义一个测试套件
  • it:定义一个测试用例
  • expect:断言的判断条件
  • toEqual:断言的比较结果
describe('test ...', function() {
	it('should ...', function() {
		expect(sth).toEqual(sth);
		expect(sth.length).toEqual(1);
		expect(sth > oth).toEqual(true);
	});
});

常用的匹配器如下:

  • toBe` 判断是否严格相等。
  • toEqual 递归检查对象或数组的每个字段。
  • toBeNull 只匹配 null
  • toBeUndefined 只匹配 undefined
  • toBeDefined 只匹配非 undefined
  • toBeTruthy 只匹配真。
  • toBeFalsy 只匹配假。
  • toBeGreaterThan 实际值大于期望。
  • toBeGreaterThanOrEqual 实际值大于或等于期望值
  • toBeLessThan 实际值小于期望值。
  • toBeLessThanOrEqual 实际值小于或等于期望值。
  • toBeCloseTo 比较浮点数的值,避免误差。
  • toMatch 正则匹配。
  • toContain 判断数组中是否包含指定项。
  • .toHaveProperty(keyPath, value) 判断对象中是否包含指定属性。
  • toThrow 判断是否抛出指定的异常。
  • toBeInstanceOf 判断对象是否是某个类的实例,底层使用 instanceof

所有的匹配器都可以使用 .not 取反:

test('Adding 1 + 1 does not equal 3', () => {
  expect(1 + 1).not.toBe(3)
})

在测试的时候,有时候我们需要在undefinednullfalse进行区别,但是我们又不想去了解他们的不同点,Jest也会帮助我们得到我们想要的结果。

  • toBeNull` 检查是否为null
  • toBeUndefined 检查是否为undefined
  • toBeDefinedtoBeUndefined的相反
  • toBeTruthy 检查任何通过if显示转换是否为true
  • toBeFalsy 检查任何通过if显示转换是否为false
test('null', () => {
    let n = null;
      expect(n).toBeNull();
      expect(n).toBeDefined();
      expect(n).not.toBeUndefined();
      expect(n).not.toBeTruthy();
      expect(n).toBeFalsy();
});

test('zero', () => {
      let z = 0;
      expect(z).not.toBeNull();
      expect(z).toBeDefined();
      expect(z).not.toBeUndefined();
      expect(z).not.toBeTruthy();
      expect(z).toBeFalsy();
});

Numbers

  • toBeGreaterThan` 大于
  • toBeGreaterThanOrEqual 大于等于
  • toBeLessThan 小于
  • toBeLessThanOrEqual 小于等于
test('two plus two', () => {
    let value = 2 + 2;
    expect(value).toBeGreaterThan(3);
    expect(value).toBeGreaterThanOrEqual(3.5);
    expect(value).toBeLessThan(5);
    expect(value).toBeLessThanOrEqual(4.5);

    // toBe and toEqual 对于number类型作用是一样的
    expect(value).toBe(4);
    expect(value).toEqual(4);
});

对于浮点数的测试,使用toBeCloseTo来替代toEqual,因为我们不会让一个测试依赖于一个微小的舍入型错误。

test('adding floating point numbers', () => {
    let value = 0.1 + 0.2;
    expect(value).not.toBe(0.3);    // It isn't! Because rounding error
    expect(value).toBeCloseTo(0.3); // This works.
});

Strings

使用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/);
})

Arrays

使用toContain对数组内的特定项进行匹配测试

let shoppingList = ['diapers', 'kleenex', 'trash bags', 'paper towels', 'beer'];

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

Exceptions

使用toThrow对一个特定函数调用时候抛出的错误进行测试

function compileAndroidCode() {
    throw new ConfigError('you are using the wrong JDK');
}

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

    // You can also use the exact error message or a regexp
    expect(compileAndroidCode).toThrow('you are using the wrong JDK');
    expect(compileAndroidCode).toThrow(/JDK/);
});

Promise

对于 Promise 对象,我们可以使用 .resolves.rejects

// .resolves
test('resolves to lemon', () => {
  // make sure to add a return statement
  return expect(Promise.resolve('lemon')).resolves.toBe('lemon')
})

// .rejects
test('rejects to octopus', () => {
  // make sure to add a return statement
  return expect(Promise.reject(new Error('octopus'))).rejects.toThrow(
    'octopus',
  )
})

异步测试

回调函数
当执行到测试代码的尾部时,Jest 即认为测试完成。因此,如果存在异步代码,Jest 不会等待回调函数执行。要解决这个问题,在测试函数中我们接受一个参数叫做 done,Jest 将会一直等待,直到我们调用 done()。如果一直不调用 done(),则此测试不通过。

// async/fetch.js
export const fetchApple = (callback) => {
  setTimeout(() => callback('apple'), 300)
}

// async/fetch.test.js
import { fetchApple } from './fetch'

test('the data is apple', (done) => {
  expect.assertions(1)
  const callback = data => {
    expect(data).toBe('apple')
    done()
  }

  fetchApple(callback)
})

Promise 如果异步代码返回 Promise 对象,那我们在测试代码直接返回该 Promise 即可,Jest 会等待其 resolved,如果 rejected 则测试不通过。

test('the data is banana', () => {
  return fetchBanana().then(data => expect(data).toBe('banana'))
})

如果期望 promise 是 rejected 状态,可以使用 .catch()

test('the fetch fails with an error', () => {
  return fetchError().catch(e => expect(e).toMatch('error'))
})

除此之外,还可以使用上文中提到的 .resolves.rejects

Async/Await
如果代码中使用到asyncawait,可以这样做。编写一个异步测试,仅需要在测试方法前面使用async关键词,然后传递给测试函数即可

test('async: the data is banana', async () => {
  const data = await fetchBanana()
  expect(data).toBe('banana')
})

test('async: the fetch fails with an error', async () => {
  try {
    await fetchError()
  } catch (e) {
    expect(e).toMatch('error')
  }
})

也可以将 aysnc/awiat 与 .resolves.rejects 结合:

test('combine async with `.resolves`', async () => {
  await expect(fetchBanana()).resolves.toBe('banana')
})

钩子函数

Jest 为我们提供了四个测试用例的钩子:beforeAll()afterAll()beforeEach()afterEach()

  • beforeAll()afterAll() 会在所有测试用例之前和所有测试用例之后执行一次
  • beforeEach()afterEach() 会在每个测试用例之前和之后执行。

Mocks

在测试中,mock 可以让你更方便的去测试依赖于数据库、网络请求、文件等外部系统的函数。 Jest 内置了 mock 机制,提供了多种 mock 方式已应对各种需求。
函数的 mock 非常简单,调用 jest.fn() 即可获得一个 mock 函数。 Mock 函数有一个特殊的 .mock 属性,保存着函数的调用信息。.mock 属性还会追踪每次调用时的 this。

// mocks/forEach.js
export default (items, callback) => {
  for (let index = 0; index < items.length; index++) {
    callback(items[index])
  }
}

import forEach from './forEach'

it('test forEach function', () => {
  const mockCallback = jest.fn(x => 42 + x)
  forEach([0, 1], mockCallback)

// The mock function is called twice
  expect(mockCallback.mock.calls.length).toBe(2)

// The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0)

// The first argument of the second call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1)

// The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42)
})

除了 .mock 之外,Jest 还未我们提供了一些匹配器用来断言函数的执行,它们本身只是检查 .mock 属性的语法糖:

// The mock function was called at least once
expect(mockFunc).toBeCalled();

使用 mockReturnValue 和 mockReturnValueOnce 可以 mock 函数的返回值。 当我们需要为 mock 函数增加一些逻辑时,可以使用 jest.fn()、mockImplementation 或者 mockImplementationOnce mock 函数的实现。 还可以使用 mockName 还给 mock 函数命名,如果没有命名,输出的日志默认就会打印 jest.fn()。

Mock 定时器

Jest 可以 Mock 定时器以使我们在测试代码中控制“时间”。调用 jest.useFakeTimers() 函数可以伪造定时器函数,定时器中的回调函数不会被执行,使用 setTimeout.mock 等可以断言定时器执行情况。当在测试中有多个定时器时,执行 jest.useFakeTimers() 可以重置内部的计数器。

执行 jest.runAllTimers(); 可以“快进”直到所有的定时器被执行;执行 jest.runOnlyPendingTimers() 可以使当前正在等待的定时器被执行,用来处理定时器中设置定时器的场景,如果使用 runAllTimers 会导致死循环;执行 jest.advanceTimersByTime(msToRun:number),可以“快进”执行的毫秒数。

Mock 模块

模块的 mock 主要有两种方式:

  • 使用 jest.mock(moduleName, factory, options) 自动 mock 模块,jest 会自动帮我们 mock 指定模块中的函数。其中,factory 和 options 参数是可选的。factory 是一个模块工厂函数,可以代替 Jest 的自动 mock 功能;options 用来创建一个不存在的需要模块。
  • 如果希望自己 mock 模块内部函数,可以在模块平级的目录下创建 mocks 目录,然后创建相应模块的 mock 文件。对于用户模块和 Node 核心模块(如:fs、path),我们仍需要在测试文件中显示的调用 jest.mock(),而其他的 Node 模块则不需要。

此外,在 mock 模块时,jest.mock() 会被自动提升到模块导入前调用。 对于类的 mock 基本和模块 mock 相同,支持自动 mock、手动 mock 以及调用带模块工厂参数的 jest.mock(),还可以调用 jest.mockImplementation() mock 构造函数。

Mock使用可以参考Jest基本使用方法以及mock技巧介绍

测试覆盖率

Jest 内置了测试覆盖率工具istanbul,要开启,可以直接在命令中添加 --coverage 参数,或者在 package.json 文件进行更详细的配置

运行 istanbul 除了会再终端展示测试覆盖率情况,还会在项目下生产一个 coverage 目录,内附一个测试覆盖率的报告,让我们可以清晰看到分支的代码的测试情况。比如下面这个例子:

// branches.js
module.exports = (name) => {
if (name === 'Levon') {
return `Hello Levon`
    } else {
return `Hello ${name}`
    }
}

// branches.test.js
let branches = require('../branches.js')

describe('Multiple branches test', ()=> {
    test('should get Hello Levon', ()=> {
          expect(branches('Levon')).toBe('Hello Levon')
    });
// test('should get Hello World', ()=> {
//       expect(branches('World')).toBe('Hello World')
// });  
})

运行 jest --coverage 可看到生产的报告里展示了代码的覆盖率和未测试的行数:

如果我们把branches.test.js中的注释去掉,跑遍测试对象中的所有分支,测试覆盖率就是100%了:

无缝迁移

如果你的项目中已经使用了别的测试框架,比如 Mocha,有一个第三方工具jest-codemods可以自动把用例迁移成 Jest 的用例,降低了迁移成本。

vocode-jest

安装jest插件
但是安装后它可能还不能很好的工作,因为 vscode-jest 暂时并不知道我们的 jest 配置文件在哪里。

  1. 修改 vscode 配置文件,将 jest.pathToConfig 指向我们刚才编写的配置文件
  2. 将 jest 配置写在 package.json 中的 jest 字段
  3. 将 jest 配置文件提到项目根目录,并且更名为 jest.config.js 或者 jest.json

现在 vs-code-jest 会根据 git修改记录自动执行应该执行的测试文件,并在控制台实时给出测试结果。至此第一部分大功告成。

使用 vue-test-util 提高测试编码效率以及复杂场景下的测试

参考 zhuanlan.zhihu.com/p/48758013

Ref

前端测试框架 Jest