Umi Jest

Mock History

umi中,history是命令行导航或说是路由的方式。我们可以通过像history.push("/")的方式进行路由跳转。当要给history添加单元测试的使用,我们可以使用下面的方式:

import { history } from 'umi';
jest.mock("umi",()=>{
    return {
        history: {
            push: jest.fn()
        }
    }
})
describe('history', function () {
    it('should be /', function () {
        expect(history.push).toBeCalledWith("/");
    });
});

这里的关键点在于:

  1. 在当前测试中添加history的显示引入
  2. 使用toBeCalledWith来判定history的push方法接收到的是我们预期的参数。

使用命令行

在使用jest的时候,我们常常会使用到命令行的方式运行测试,这可以给予我们一定的自由度来控制测试的运行。

方式

在命令行中使用jest有几种方式,其一就是直接使用已安装的本地jest,使用方式如下:

./node_modules/bin/jest --可选参数

其二是使用全局安装的jest脚本,使用方法如下:

jest --可选参数

其三是使用pnpm,npmyarn这样的包管理器运行。这里以pnpm为例。在使用这种方式的时候,我们通常需要在package.json中添加一个脚本命令,如:

{
  "script": {
    "test": "jest"
  }
}

当我们使用这样的方式来运行jest的时候,命令行要用以下的方式:

pnpm test -- --参数

具体参数

--json

JSON的形式输出测试结果

--lastCommit

运行上一次commit中的所有测试

--onlyChanged

只运行当前仓库中修改文件的测试。别名:-o

注意:这个命令只有在项目启用了githg仓库时才有用

--silent

静默运行测试,不在控制台打印测试信息

--bail[=]

当失败的测试套件达到指定个数的时候,立即退出。默认时为1。别名:-b

--changedSince

只运行提供的commit hash分支后的修改。

--coverage[=]

指定是否要测试代码覆盖率。别名:--collectCoverage

--coverageDirector=

指定代码覆盖率输出的目录

--coverageProvider=

指定使用哪个选项来检测代码的覆盖率。可选项有babelv8。默认为babel

--debug

使用调试样式运行Jest

--env=

指定Jest运行的测试环境。可以指定任意的文件或node模块。如jsdom,nodepath/to/my-envoriment.js.

--expand

用于显示测试失败时详细的区别,而不是使用部分区别。别名:-e

--init

以会话的形式初始化一个jest.config.js的配置文件。

--listTests

列表检测到的所有测试

--noStackTrace

在输出中,忽略堆栈追踪

--passWithNoTests

Jest检测到一个测试文件内没有任何测试时,将其标记为通过。

默认情况下,当一个测试文件内不包含测试时,会默认测试失败。

--showConfig

显示Jest的配置内容

--findRelatedTests

查找并运行以空格分隔的一系列和源文件有关的测试。

--testNamePattern=

运行测试名符合给定的正则表达式的测试。别名:-t

配置文件

代码覆盖率

当在测试中需要输出代码覆盖率的时候,我们需要进行一定的配置,下面是一个配置示例:

module.exports = {
    collectCoverage: true,
    coverageThreshold: {
        global: {
            branches: 95,
            functions: 95,
            lines: 95,
            statements: 95
        },
        './src/reducers/**/*.ts': {
            statements: 90
        },
        './src/utils/**/*.ts': {
            functions: -5
        }
    },
    coverageDirectory: 'reports',
    collectCoverageFrom:["src/**/*.ts","!**/node_modules/**"],
    coveragePathIgnorePatterns: ["<rootDir>/dist/","<rootDir>/node_modules/"],
    coverageProvider: "babel",
    coverageReporters: ["clover","json",'lcov',['text',{skipFull: true}]]
}

在这个配置示例中,我们对其中的各个字段分别进行一下讲解:

  • collectCoverage字段为一个boolean值,指定其为true时,才会触发代码覆盖率的收集操作
  • coverageThreshold字段是对代码覆盖率的统计指标的定义。其可以定义一个全局的代码覆盖率的指标,用global表示,其中要求branches(条件分支)的覆盖率最少达到95%functions(函数)的代码覆盖率最少也要达到95%lines代码行的代码覆盖率也要达到95%,statements(语句)的代码覆盖率也要达到95%。此外,我们还可以单独指定某个匹配模式下对应的代码覆盖率,比如示例中./src/reducers/**/*.ts模式下statements的代码覆盖率达到90%即可。也可以指定一个负值,用于表示没有覆盖的测试的最大数量,比如示例中./src/utils/**/*.ts模式下,functions没有测试的最大数量为5。一旦测试的代码覆盖率达不到我们设置的指标,那么测试就会失败。
  • coverageDirectory用于指定代码覆盖率输出报告的文件目录,如果没有设置这个属性,那么报告会输出到项目目录的coverage目录中。
  • collectCoverageFrom用于指定从什么地方收集要测试的代码。
  • coveragePathIgnorePatterns用于指定测试时,要忽略哪些目录下的代码。
  • coverageProvider用于指定输出报告的提供者,可选的有babelv8,默认使用babel
  • coverageReporters用于指定输出报告的格式。默认值为["clover", "json", "lcov", "text"]

除了在示例中提及到的几个有关代码覆盖率的选项外,还有一个forceCoverageMatch选项,其主要用于从上面的配置中已忽略的部分再添加进测试中。

报告格式

全局对象

为了方便在测试中使用,Jest自动在测试文件中注入了全局对象describe,testexpect。通过自动注入的方式使得我们在测试文件中不再需要显示的重复import {describe, expect, text} from '@jest/globals'引用了。

Jest的全局对于可以分为以下几类:

  • 钩子类
    • afterAll
    • beforeAll
    • afterEach
    • beforeEach
  • describe类
  • test类
  • expect类

因为expect类比较重要,所以单独记录。

全局对象 test

Jest中,全局对象test有一个别名it,在也就是说,在Jest中能使用test的地方也可以使用it来替代。

test

test对象最简单的使用如下:

test('testName', () => {
    expect(1 + 1).toBe(2)
})

test函数接收三个参数。

  • 第一个参数为测试方法的名称,通过这个名称可以在测试失败的时候,更加精确的定位是哪个测试失败了。
  • 第二个参数是一个匿名函数。在这个函数中应该包含本测试的准备工作和一个或多个断言函数。(推荐只在一个test 方法中包含一个断言函数,虽然Jest允许在test中包含多个断言函数)。
  • 第三个参数(可选)是测试运行的超时时间,如果函数运行超时,则自动失败。默认是5s。

如果test第二个参数的函数返回一个Promise,那么,在Promise状态改变后,断言才会生产。比如asyncBe是返回一个Promise 。那么可以像这样测试:

test("test the asyncBe", () => {
    return asyncBe(1).then(v => {
        expect(v).toBe(1)
    })
})

关于异步函数的测试,请参考异步函数的测试

test.only

test.only可以使Jest 只运行一个测试块中当前的测试。这在调试测试的时候会非常有用。它可以让你只运行当前的测试,而忽略其它测试,以此来提升测试速度和减少反馈时间。test.only 有一个别名:fit

示例如下:

test.only('it is raining', () => {
    expect(inchedOfRain()).toBeGreaterThan(0);
});

test('it is not snow', () => {
    expect(inchedOfSnow()).toBe(0);
});

在上面的示例中,当运行测试的时候,只会运行it is raining测试,因为其标记了only

注意:请不要把test.only提交到仓库中,它应该只是你调试测试的一个常用工具。

test.todo

test.todo用于指定计划添加测试的时候。其只接收一个参数,即测试的名称。使用test.todo 标记的测试会在最后的汇总的时候高亮,所以可以方便的知道还有哪些计划的测试需要添加。

注意:test.todo如果包含了函数会抛出错误。如果你已经有了一个实现测试的测试,但不想运行它。请使用todo.skip

test.skip

当正在维护一个很大的代码的时候,如果一个测试因为某些原因临时遭到了破坏。但又不想删除它。只想临时跳过这个测试。这个时候就可以使用test.skip 。别名:xit,xtest

示例如下:

test('it is raining', () => {
    expect(inchesOfRain()).toBeGreaterThan(0)
});

test.skip('it is not snowing', () => {
    expect(inchesOfSnow()).toBe(0)
})

上面的代码只会运行it is raining。因为其使用了test.skip

test.each

当你需要使用不同的数据来运行测试的时候可以使用test.each。先来看示例:

test.each([
    [1, 1, 2],
    [1, 2, 3],
    [2, 1, 3]
])('.add(%i, %i)', (a, b, expected) => {
    expect(a + b).toBe(expected);
})
test.each([
    {a: 1, b: 1, expected: 2},
    {a: 1, b: 2, expected: 3},
    {a: 2, b: 1, expected: 3}
])('.add($a, $b)', ({a, b, expected}) => {
    expect(a + b).toBe(expected);
})

通过示例我们可以发现test.each的第一个参数是一个二维数组。其返回值接收3个参数。

  • 第一个参数:测试名。
    • 其名称是可以通过printf的参数
      • %p 美化格式
      • %s 字符串
      • %d 数字
      • %i 整数
      • %f 浮点数
      • %j JSON
      • %o 对象
      • %# 测试的索引
      • %% %
    • 或是对象解构的参数,如$a,$b
  • 第二个参数,测试函数。
  • 第三个参数(可选),超时时间,默认为5s。

test.concurrent

test.concurrent目前是一个实验性的功能。其作用是显式指定其测试开发运行。其包含的函数和test基本一致。只是第二个参数接收的是一个包含有断言的 异步函数.

示例:

test.concurrent('addition of 2 numbers', async () => {
    expect(5 + 3).toBe(8);
});

test.concurrent('subtracting 2 numbers', async () => {
    expect(5 - 3).toBe(2);
})

注意:当在测试中使用了test.concurrent时,最配置maxConcurrency选项,用来防止Jest同时运行太多的测试。

钩子类全局对象

describe全局对象

Expect断言

Jest中有40多种断言匹配器,还有三种修饰符。这40多种断言匹配器可以分为多个种类。

基本类型断言

基本类型断言匹配器有以下几个:

toBe

toBe匹配器在Jest中的主要作用是用于断言基本类型的。其使用Object.is来判断断言结果,而不是使用的全等运算符(===)。

使用toBe 判断对象是否相等的时候,其判断是的两个对象是不是使用的同一个引用,而不是比较其字面值是否相等,如果要比较再个对象的字面量是否相等,请使用toEqual

请不要使用toBe来比较两个浮点数是否相等,因为实现的原因,在JavaScript和其它大多数的语言中,0.1+0.2并不完全等于0.3。( 因为精度丢失)。如果要比较两个浮点数,请使用toBeCloseTo

注意:Object.is和全等运行符在+0-0Number.NaNNaN 的运算上是不一样的。其他时候的运算结果是一致的。区别如图:Object.is vs ===

toEqual

toEqual方法在处理基础类型上和toBe的行为是一致的。但在处理复杂类型(对象,数组,Set,Map等)时,toBe 是直接比较左右两边是否指向同一个引用。而toEqual则会对这些复杂类型进行递归调用toEqual方法,最终判断是字面量是否相等。如:

test("toEqual example", () => {
    const a = {
        id: 1,
        lang: 'JS'
    };
    const b = {
        id: 1,
        lang: 'JS'
    };
    expect(a).toEqual(b);
    expect(a).not.toBe(b);
})

toBeDefined

断言一个变量或一个函数的返回値是已经定义的。比如:

test('assert toBeDefined', () => {
    expect(1).toBeDefined()
})

注意,如果要断言一个变量或返回值是未定义的,相比下not.toBeDefinedtoBeUndefined是更好的选择。

toBeTruthy

断言一个值是真值,而不仅仅是true。比如:

test('assert toBeTruthy', () => {
    expect(1).toBeTruthy();
})

注意:在JavaScript中,只有false,'',0,null,undefinedNaN是假值,其余的都是真值。

如果要判断一个值为假值,相比下not.toBeTruthytoBeFalsy是更好的选择。

toBeFalsy

断言一个值是假值。而不仅仅是false。比如:

test(`assert toBeFalsy`, () => {
    expect(0).toBeFalsy();
})

注意:在JavaScript中,只有false,'',0,null,undefinedNaN是假值,其余的都是真值。

如果要判断一个值为假值,相比下not.toBeFalsytoBeTruthy是更好的选择。

toBeNaN

判断一个值是NaN

test('assert toBeNaN', () => {
    expect(NaN).toBeNaN();
    expect(1).not.toBeNaN();
})

注意,在JavaScript中目前有两种方法来判断一个值是不是NaN

  • Number.isNaN
  • NaN===NaN返回值为false,即NaN是JavaScript中唯一一个不等于自身的。

toBeNull

判断一个值是nulltoBeNull在逻辑上等价于toBe(null)。但当断言失败的时候,toBeNull的提示信息更加友好。

test('assert toBeNull', ()=>{
    expect(null).toBeNull();
})

toBeCloseTo

toBeCloseTo 用于判定两个给定的浮点数在指定的精度下是否相等。之所以需要指定精度,是因为在计算机的底层使用的是二进制表示的十进制的数字。并不是每一个十进制小数都可以使用二进制精确的表示,这就会丢失精度。所以才有了在指定精度下判断相等的方法。

toBeCloseTo的签名为toBeCloseTo(number, numDigits?):

  • numDigits为精度,可选,其默认值为2.其表示Math.abs(expected - received) < 0.005。其中,0.0052 的关系是:(10**-2)/2

示例代码如下:

test('assert 0.1+0.2 and 0.3', () => {
    expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
})

toBeUndefined

用于断言一个变量或返回值是未定义的。比如:

test('assert toBeUndefined', () => {
    let a;
    expect(a).toBeUndefined();
})

注意,如果要断言一个变量或返回值是已定义的,相比下.not.toBeUndefinedtoBeDefined()是更好的选择。

toBeGreaterThan

使用received>expected来进行断言。 比如:

expect('assert toBeGreaterThan', ()=>{
    expect(12).toBeGreaterThan(10)
})

toBeGreaterThanOrEqual

使用received>=expected进行断言。 比如:

expect('assert toBeGreaterThanOrEqual', () => {
    expect(12).toBeGreaterThanOrEqual(12);
})

toBeLessThan

使用received<expected进行断言。 比如:

expect('assert toBeLessThan', ()=>{
    expect(12).toBeLessThan(15)
})

toBeLessThanOrEqual

使用recieved<=expected进行断言。 比如:

expect('assert toBeLessThanOrEqual', ()=>{
    expect(12).toBeLessThanOrEqual(12)
})

数组类断言

Jest中和数组相关的断言匹配器有下面一些:

toContain

toContain用于判断数组中是否包含指定的元素。其使用===来判断数组中的项目和给定内容的比较。比如:

const array = [1, 2, 3];
test('assert toContain', () => {
    expect(array).toContain(2);
})

toContain不仅仅用于数组,还可以用于其他可迭代类型,比如字符串,集合,node集合HTML集合等。

注意,因为toContain中使用的是===。所以,如果数组中的项目是对象,那么比较的是对象的引用是否是同一个,而不是比较字面量。

toContainEqual

toContainEqual同样是用于判断数组中是否包含指定的元素。其和toContain的区别在于,toContain 对于复杂类型的比较是比较其引用。而toContainEqual是比较其字面量。就和toBetoEqual一样。 比如:

const array = [{id: 1}, {id: 2}];
test(`assert toContainEqual`, () => {
    expect(array).toContainEqual({id: 1})
})

toHaveLength

toHaveLength用于检查一个数组中元素的个数或字符串的长度。比如:

test('assert toHaveLength', () => {
    expect([1, 2, 3]).toHaveLength(3);
})

注意,toHaveLength不仅仅可以用于数组和字符串,它可以用于任何有length属性的对象。用于判断其length是否等于指定的数值。

expect.arrayContaining

expect.arrayContaining(array)可以用于判断一个数组是不是另一个数组的子数组。其主要需要配合toEqualtoBeCalledWith使用。还可以配合objectContainingtoMatchObject使用。

it('assert arrayContaining', function () {
    const expected = [1, 2, 3];
    expect([1, 2, 3, 4]).toEqual(expect.arrayContaining(expected));
}); 

测试异步函数

Jest中测试异步函数主要有4种方法:

  • test中返回的Promise中断言
  • 使用asyncawait
  • 在回调函数中使用done函数
  • 使用resolvesrejects

返回Promise

如果要测试的函数返回的是一个Promise,那么我们就可以让test返回Promise,在Promise中使用断言。比如:

test('the data is peanut butter', () => {
    return fetchData().then(data => {
        expect(data).toBe('peanut butter');
    })
})

这样,我们就可以测试这个Promise函数了。

注意:我们一定要返回Promise,让Jest知道这是一个异步函数,在Promiseresolve 后进行断言,否则,Jest会提前断言。这个时候,Promise的状态还没有改变,得不到我们想要的结果。如果Promise没有resolve ,而是jest了,那么测试会直接失败。

async/await

在测试异步代码的时候,我们可以直接使用asyncawait。就像下面一样:

test('the data is peanut butter', async () => {
    const data = await fetchData();
    expect(data).toBe('peanut butter');
})

test('the fetch fails with an error', async () => {
    expect.assertions(1);
    try {
        await fetchData();
    } catch (e) {
        expect(e).toMatch('error');
    }
})

在使用async/await测试异步代码的时候,我们要给test函数添加async标记,在异步代码前使用await

当使用async/await测试异步代码失败的时候,记得一定要使用assertions来断言测试有过断言,否则测试不能通过。

使用回调函数

如果我们要测试的异步代码是在回调函数中,而不是在Promise里,那么我们就需要使用test函数中单参数done来解决问题了。比如:

test('the data is peanut butter', done => {
    function callback(error, data) {
        if (error) {
            done(error);
            return;
        }
        try {
            expect(data).toBe('peanut butter');
            done();
        } catch (error) {
            done(error);
        }
    }

    fetchData(callback);
})

在这个例子中,如果done()函数从来没有执行过,那么测试会超时失败。

如果expect执行失败,那么其将抛出一个错误,其后的done 就不会执行。如果我们想要知道具体为什么会失败,那么我们就要在测试中捕获这个错误。所以需要把expect放入try中,然后在catch 中捕获错误,使用done来接收error的值。

使用resolvesrejects

我们也可以让test中的函数返回一个resolvesrejects来测试异步代码。比如:

test('the data is peanut butter', () => {
    return expect(fetchData()).resolves().toBe('peanut butter');
})

直接通过resolves来声明我们期望Promise的状态会变为resolve状态,并返回peanut butter,否则测试失败(无论Promise 是变为reject还是resolve后返回的不是peanut butter)。

同理,通过rejects,我们期望Promise的状态会变为rejects,并返回一个指定的错误。如:

test('the fetch fails with an error', () => {
    return expect(fetchData()).rejects.toMatch('error');
})

测试RxJs中Promise的行为

因为RxJs的实现机制和Promise微任务的问题,在RxJs中不能使用弹珠测试,那么我们就要使用Jest中测试异步代码中的方法来解决问题了。比如:

// Some RxJS code that also consumes a Promise, so TestScheduler won't be able
// to correctly virtualize and the test will always be really asynchronous.
const myAsyncCode = () => from(Promise.resolve('something'));

it('has async code', (done) => {
    myAsyncCode().subscribe((d) => {
        assertEqual(d, 'something');
        done();
    });
});

Mock

Jest测试中,Mock的重要性丝毫不亚于expect断言的重要性。因为在很多的场合,没有Mock,测试会很难执行甚至于不能执行。

常用的Mock场景:

location的mock

在开发浏览器应用的时候,我们会不可避免的使用到window.location对象(后面直接称为location对象) 。当我们的方法使用到location对象的时候,要对这个方法进行单元测试,那我们就需要使用方法来mock这个location对象。

下面是我们要mocklocation的方法和步骤:

安装依赖

我们需要知道的是,我们使用location的场景是在浏览器端,所以我们的测试环境也应该是和浏览器相关的。这里我们使用jsdom 环境。首先,我们需要安装依赖jest-environment-jsdom:

pnpm add -D jest-environment-jsdom

设置测试环境

设置测试环境的方法有两种,一种是在配置文件中指定全部测试的测试环境,一种是测试文件中单独指定测试文件自身的测试环境。我们这里使用第二种方法:

/**
 * @jest-environment jsdom
 */

设置mock方法

为了使用方法,我们可以提取一个公共的mock方法,如下:

function stubLocation(location: Record<string, any>) {
    jest.spyOn(window, "location", "get").mockReturnValue({
        ...window.location,
        ...location,
    });
}

之后,我们就可以直接在测试中调用stubLocation方法为location进行mock了。

总结

我们通过安装依赖,设置测试环境和添加公共的mock方法,实现了对location的包装。具体示例如下:

pnpm add -D jest-environment-jsdom
/**
 * @jest-environment jsdom
 */

test("test location", () => {
    stubLocation({url: "https://www.exmaple.com"});
    expect(localtion.url).toBe("https://www.example.com")
})

function stubLocation(location: Record<string, any>) {
    jest.spyOn(window, "location", "get").mockReturnValue({
        ...window.location,
        ...location,
    });
}