保姆级 Jest 教程

571 阅读11分钟

jest 覆盖率

本文不适合纯白用户,建议先阅读 jest 的Getting Started:jestjs.io/docs/gettin… 再来阅读本文,体感更好。

通过 jest --coverage 也叫 collectCoverage,该指令可以收集测试覆盖率信息并在输出中报告。 截图如下: image.png

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

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

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

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

  • Uncovered Line : 未覆盖到的行数

回调函数

异步场景

例如: 一个的业务场景:请求函数,请求成功后将数据放到callback中,进行数据处理。

demo 如下:

function fetchData(cb) {
    setTimeout(() => {
        cb('hello world')
    }, 1000)
};

测试代码

test('异步常景', (done) => {
    fetchData((params) => {
        try {
            expect(params).toBe('hello world') // ok
            done()
        } catch (error) {
            done(error)
        }
    })
})
  1. 此时必须添加 done 作为回调的结束,如果不添加 done 函数,则会提示超时。
    因为 fetchData 调用结束后,此测试就提前结束了。 并未等 callback 的调用。 image.png
  2. 如果在测试过程中遇到不符合预期的情况,可以使用try catch将错误的信息传递给 done 的第一个参数。
  3. 如果不传递错误信息。遇到错误也可以通过测试,此时的测试结果是不准确的。
// 此测试用例也可以通过
test('callback', (done) => {
    fetchData((params) => {
        try {
            throw Error('error');
            done()
        } catch (error) {
            done()
        }
    })
})

同步回调

test('同步场景',() => {
    const fun = (cb) => {
        cb && cb()
    };
    const cbFun = jest.fn();
    fun(cbFun);
    expect(cbFun).toBeCalled(); // ok
})

异步函数

异步代码如下

function fetchData(type){
  return type ? Promise.resolve('success') : Promise.reject('error')
};

测试逻辑

  1. 通过 promise 的方式

 此处必须return ,把整个断言作为返回值返回。如果你忘了return语句的话,在 fetchData 返回的这个 promise 变更为 resolved 状态、then() 有机会执行之前,测试就已经被视为已经完成了

test('promise rejected', () => {
  return expect(fetchData(false)).reject.toBe('error')
})

test('promise resolve', () => {
  return expect(fetchData(true)).resolve.toBe('success')
})

  1. 通过 async / await 的方式
test('async resolve', async () => {
  let res = await fetchData(true);
  expect(res).toBe('success');
})

test('async reject', async () => {
  try {
    await fetchData(false);
  } catch (error) {
    expect(error).toMatch('error');
  }
})

注意:
expect.assertions(1) 推荐添加。如果 fetchData 不执行 reject,测试用例依旧可以通过,但是如果添加 expect.assertions(1) ,则要求此测试用例至少要被运行一次。即 必须 mock 一次 promise.reject的情况

try catch

demo 代码

const tryCatch = (data) => {
    try {
        data.push(77);
    } catch (error) {
        console.error('tryCatch', error.message);
        return null
    }
}

测试逻辑

    it('should throw an error if an exception is thrown', () => {
        // 使用 spyOn 进行模拟 console 方法,监听 console.error 方法
        const spy = jest.spyOn(console, 'error');
        const result = tryCatch(123);
        // 测试返回值
        expect(result).toBeNull();
        // 传递的参数第一个是 tryCatch
        expect(spy).toHaveBeenCalledWith('tryCatch', expect.stringContaining(''))
        // 恢复原来 console.error 方法。(防止影响后续流程)
        spy.mockRestore();
    });

jest.spyOn 方法可以替换模块中原有的方法。例

jest.spyOn(console,'error').mockImplementation(() => 'this is error')
console.error()  // this is error

模拟-模块

模拟-本地模块

1. 方式一
假如我们要模拟一个 index.js 的模块。
首先在 index.js 文件同级目录新建一个名字为 __mocks__ 的文件夹,再创建一个名字与要模拟模块相同的文件。
例如:

├── __mocks__
│   └── index.js
└── index.js

Demo 示例

├── __test__
│   └── index.test.jsx
├── index.jsx
└── service
    ├── __mocks__
    │   └── index.js
    └── index.js
// index.jsx
import React from "react";
import localModule  from "./service/index.js";

export const InputCom = () => {
  return <div>
  {localModule.name}
  </div>;
};

// service/index.js
module.exports = {
    name: 'localModule'
}
// service/__mocks__/index.js
module.exports = {
    name: 'mock localModule'
}
// index.test.js
import { render, screen } from "@testing-library/react";
import { LocalTest } from "..";
import React from "react";
import '../service'
jest.mock('../service');

test("测试本地模块", () => {
  render(<LocalTest></LocalTest>);
  screen.debug()
});

测试结果:

image.png

注意:有两个注意事项
1、该__mocks__文件夹区分大小写,因此__MOCKS__在某些系统上命名该目录会中断。 2、我们必须显式调用jest.mock('./moduleName')

2. 方式二

通过 jest.mock 方法,在文件内进行数据的模拟。不创建 __mocks__ 文件。

我们改写测试文件。

// index.test.js
import { render, screen } from "@testing-library/react";
import { LocalTest } from "..";
import React from "react";
import '../service'
jest.mock('../service',() => ({
    name: 'mock localModule'
}));

test("测试本地模块", () => {
  render(<LocalTest></LocalTest>);
  screen.debug()
});

测试的结果与方式一一样。

模拟-第三方模块

1. 方式一

  • 我们在与node_modules 同级目录下创建__mocks__
  • __mocks__下面创建我们需要模拟的第三方模块,格式:@scoped/moduled-name.js,例如: @testing/fun。 我们创建 __mocks__/@testing/fun.js 文件。
├── package.json
├── node_modules
├── index.js
├── __mocks__
│   └── @testing
│       └── fun.js
├── __test__
│   └── index.test.jsx
├── index.jsx
// index.jsx
import React from "react";
import nodeModule from '@testing/fun';

export const NodeModuleTest = () => {
  return <div>
    {nodeModule?.name}
  </div>;
};

// @testing/fun
module.exports = {
    name: 'module-name',
    age: 'module-age',
    fun: () => 'module-fun'
}
// __mocks__/@testing/fun.js
module.exports = {
    name: 'mock-nodeModule',
    fun: () => 'mock-fun'
}
//index.test.js
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import React from "react";
import NodeModuleTest from '..'
it("测试第三方模块", () => {
    render(<NodeModuleTest />)
    screen.debug();
});

运行测试用例以后,结果:
image.png

⚠️ 注意:
1、模拟第三方模块无须显示调用 jest.mock
2、Node 内置的模块,我们需要显示的调用 jest.mock 例如 fs ,我们需要手动的调用jest.mock(fs)

2. 方式二

//index.test.js
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import React from "react";
import NodeModuleTest from '..'
jest.mock("@testing/fun", () => ({
  name: "mock-module-jest.mock",
}));
it("测试第三方模块", () => {
    render(<NodeModuleTest />)
    screen.debug();
});

运行测试用例以后的结果:

image.png

即使有__mocks__/@scoped/project-name.js 文件,在项目中的单独写的 jest.mock 方法权重最高。

我们模拟的时候后,可能有以下的诉求: 假设第三方包导出了 a,b,c 三个方法

  • b 方法使用真实方法,其他导出使用模拟的方法。
  • 仅模拟 a 方法,其他导出使用真是方法。

以上两种情况我们都可以使用 jest.requireActual() 来解决。

// index.jsx
import React from "react";
import nodeModule from '@testing/fun';

export const NodeModuleTest = () => {
  return <div>
    {nodeModule?.name}
    {nodeModule?.age}
    {nodeModule?.sex}
  </div>;
};
// @testing/fun
module.exports = {
    name: 'module-name',
    age: 'module-age',
    sex: 'module-sex'
}
//index.test.js 
import { screen } from "@testing-library/react"; 
import "@testing-library/jest-dom"; 
import React from "react"; 
import NodeModuleTest from '..' 
jest.mock("@testing/fun", () => {
  const originalModule = jest.requireActual("@testing/fun");
  return {
    // __esModule: true, // 处理 esModules 模块
    ...originalModule,
    name: "mock-module-jest.mock",
  }
})
it("测试第三方模块", () => { render(<NodeModuleTest />) screen.debug(); });

image.png

函数测试

.mock 属性

在学习函数测试之前,我们需要先了解.mock属性都有哪些?
测试 demo 如下:

test('.mock', () => {
   const mockFun = jest.fn(() => {});
   console.log(mockFun.mock);
});

结果如下: image.png 实际上是 6 个属性,还有一个 lastCall 方法,这个方法只有在模拟的函数被调用的时候才会存在。

属性名称属性含义
calls包含对此模拟函数进行的所有调用的调用参数的数组。数组中的每一项都是调用期间传递的参数数组
contexts包含模拟函数的所有调用的上下文的数组
instances一个数组,其中包含已使用通过 new 模拟函数实例化的所有对象实例
invocationCallOrder包含被调用的顺序
results包含模拟函数的所有调用的上下文的数组
lastCall包含对此模拟函数进行的最后一次调用的调用参数的数组。如果函数没有被调用,它将返回undefined

光看介绍可能不足以让我们了解到底是如何玩的,继续向下看:

  • calls

包含对此模拟函数进行的所有调用的调用参数的数组。数组中的每一项都是调用期间传递的参数数组

//mockProperty.test.js
test('.mock.calls', () => {
    const mockFun = jest.fn(() => {});
    mockFun(1)
    mockFun(2)
    console.log("%c Line:27 🍯 mockFun", "color:#b03734", mockFun.mock);
});

image.png 如上图所示,mockFun 方法调用了两次,分别传了1,2两个参数,打印结果中 calls 则是记录每次调用的入参。 即使调用的时候没有传参数, calls 依旧会记录调用的次数。

test('.mock.calls', () => {
    const mockFun = jest.fn(() => {});
    mockFun()
    mockFun()
    console.log("%c Line:27 🍯 mockFun", "color:#b03734", mockFun.mock.calls);
});

image.png

  • contexts

包含模拟函数的所有调用的上下文的数组

test('.mock.contexts', () => {
    const mockFn = jest.fn();
    const thisContext0 = () => {}
    const thisContext1 = () => {}
    const thisContext2 = () => {}
    const boundMockFn = mockFn.bind(thisContext0);
    boundMockFn();
    mockFn.call(thisContext1);
    mockFn.apply(thisContext2);
    console.log("%c Line:55 🍞 mockFn.mock", "color:#ea7e5c", mockFn.mock);
})

打印结果如下

image.png contexts 中记录了本次调用 this 所属上下文

  • instances

一个数组,其中包含已使用通过 new 模拟函数实例化的所有对象实例

test('.mock.instances', () => {
    const mockFn = jest.fn();
    const boundMockFn1 = new mockFn();
    const boundMockFn2 = new mockFn();
    const boundMockFn3 = new mockFn();
    console.log("%c Line:55 🍞 mockFn.mock", "color:#ea7e5c", mockFn.mock.instances[0] == boundMockFn1); // true
    console.log("%c Line:55 🍞 mockFn.mock", "color:#ea7e5c", mockFn.mock.instances[1] == boundMockFn2); // true
    console.log("%c Line:55 🍞 mockFn.mock", "color:#ea7e5c", mockFn.mock.instances[2] == boundMockFn3); // true
})
  • invocationCallOrder

包含被调用的顺序

image.png

使用上面的 instance 测试方法,打印结果 .mock 属性,可以在invocationCallOrder 方法中看到每个方法被调用的顺序。

  • results

包含模拟函数的所有调用的上下文的数组

用的比较多的属性。

  • 'return'- 表示调用正常返回完成。
  • 'throw'- 表示调用通过抛出值完成。
  • 'incomplete'- 表示呼叫尚未完成。如果您从模拟函数本身或从模拟调用的函数中测试结果,就会发生这种情况。
//type 为 return 的情况
test('.mock.result', () => {
    const mockFn = jest.fn(() => {
        return ''
    });
    mockFn(1)
    console.log("%c Line:55 🍞 mockFn.mock.result", "color:#ea7e5c", mockFn.mock.results);
})

image.png

// type 为 throw
test('.mock.result', () => {
    const mockFn = jest.fn(() => {
        throw Error('error')
    });
    try {
        mockFn()
    } catch (error) {
        
    }
    console.log("%c Line:55 🍞 mockFn.mock.result", "color:#ea7e5c", mockFn.mock.results);
})

image.png

// type 为incomplete
test('.mock.result', () => {
    const mockFn = jest.fn(() => {
        console.log("%c Line:55 🍞 mockFn.mock.results", "color:#ea7e5c", mockFn.mock.results);
    });
    mockFn()
})

image.png

  • lastCall

包含对此模拟函数进行的最后一次调用的调用参数的数组。如果函数没有被调用,它将返回undefined

test('.mock.lastCall', () => {
    const mockFn = jest.fn(() => {
    });
    mockFn()
    mockFn(2)
    console.log("%c Line:55 🍞 mockFn.mock.lastCall", "color:#ea7e5c", mockFn.mock.lastCall);
})

image.png

test('.mock.lastCall', () => {
    const mockFn = jest.fn(() => {
    });
    console.log("%c Line:55 🍞 mockFn.mock.lastCall", "color:#ea7e5c", mockFn.mock.lastCall);
})

image.png

一个模拟函数f被调用了 3 次,返回'result1',抛出错误,然后返回'result2',将有一个mock.results如下所示的数组:

[  
    {  
        type: 'return',  
        value: 'result1',  
    },  
    {  
        type: 'throw',  
        value: {  
        /* Error instance */  
        },  
    },  
    {  
        type: 'return',  
        value: 'result2',  
    },  
];

模拟函数

  • 创建模拟函数
    模拟函数有三种方式
方法介绍
jest.mock模块的模拟
jest.fnjest.fn()是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值
jest.spyOnjest.fn 的语法糖,可以监控模拟函数的调用情况

三种方式都可以用来模拟函数。 jest.mock 不再做介绍.
jest.fn

onchange = jest.fn() // 返回 undefined
// 可以模拟实现
onchange = jest.fn(() => console.log('log'));

jest.spyOn


let car = {
  stop: () => "stop",
};
jest.spyOn(car,'stop').mockImplementation(() => 'mock-stop'); // mock-stop
  • 模拟函数返回

    • mockReturnValue

    函数的内容将不会被执行

    let mockFun = jest.fn().mockReturnValue('mockReturnValue')
    mockFun() // mockReturnValue
    
    • mockReturnValueOnce
        let mockFun = jest.fn(() => 'returnValue').mockReturnValueOnce('mockReturnValue')
        mockFun()  // mockReturnValue
        mockFun()  // returnValue
    
    
    • mockResolvedValue
        let mockFun = jest.fn(() => 'returnValue').mockResolvedValue('mockReturnValue')
        mockFun()  // Promise { 'mockReturnValue' }
    
    
    • mockResolvedValueOnce
        let mockFun = jest.fn(() => 'returnValue').mockResolvedValue('mockReturnValue')
        mockFun()  // Promise { 'mockReturnValue' }
        mockFun()  // Promise { 'returnValue' }
    
    
    • mockRejectedValue
        let mockFun = jest.fn(() => 'returnValue').mockRejectedValue('mockReturnValue')
        await mockFun().catch(err => {
            console.log(err) // mockReturnValue
        })
    
    
    • mockRejectedValueOnce
      同上

    除了单独使用还可以连在一起使用,例如:

    let mockFun = jest.fn(() => 'returnValue')
        .mockReturnValue('default')
        .mockReturnValueOnce('first')
        .mockReturnValueOnce('two')
        .mockResolvedValueOnce('resolved')
        .mockRejectedValueOnce('rejected')
        
        mockFun(); // first
        mockFun(); // two
        mockFun(); // Promise { 'resolved' }
        mockFun(); // Promise { <rejected> 'rejected' }
        mockFun(); // default
    

测试

一般函数测试主要分为几种:

  • 测试函数的调用情况
  • 测试函数的返回值

我们在上面已经了解到了mockFun.mock属性,以及mockFun 的创建方式。下面进入本小节的核心,如何测试函数.

  • 函数调用 通过 .mocks.calls 属性的长度,判断函数是否被调用、调用的次数、调用的参数。

    • 函数是否被调用
    
    test('.mock.calls', () => {
        const mockFun = jest.fn(() => {});
        mockFun()
        expect(mockFun.mock.calls.length).toBe(1);
        mockFun()
        expect(mockFun.mock.calls.length).toBe(2); // ok
    });
    
    • 函数接收到的参数
    test('.mock.calls', () => {
        const mockFun = jest.fn(() => {});
        mockFun(1)
        mockFun()
        expect(mockFun.mock.calls[0][0]).toBe(1); // ok
        expect(mockFun.mock.calls[0][1]).toBeUndefined() // ok
    });
    
  • 函数返回 通过 .mock.results 属性判断函数的结果

    test('.mock.calls', () => {
        const mockFun = jest.fn((params) => params);
        mockFun(1)
        mockFun(2)
        expect(mockFun.mock.result[0].value).toBe(1); // ok
        expect(mockFun.mock.result[1].value).toBe(2); // ok
    });

自定义匹配器

在测试一节,可以看到要判断一个函数的调用、返回相对比较麻烦,是否有简单的方式能够断言函数是否调用与返回呢?答案是有的,我们继续往下看:

  • 是否被调用 toBeCalled也叫做 toHaveBeenCalled
const fun = jest.fn();
expect(fun).toBeCalled() // error
fun();
expect(fun).toBeCalled() // ok
  • 调用次数 toHaveBeenCalledTimes
  • 调用参数 toHaveBeenCalledWith
  • 是否有返回值(没有抛出错误) toHaveReturned
  • 返回值 toHaveReturnedWith

定时器

API作用
useFakeTimers对文件中的所有测试使用假计时器,直到原始计时器恢复为jest.useRealTimers().
useRealTimers恢复全局日期、性能、时间和计时器 API 的原始实现.
runAllTimers执行所有挂起的宏任务和微任务.
runOnlyPendingTimers仅执行当前挂起的宏任务(即,仅执行到此时或setInterval()之前已排队的任务)。如果任何当前挂起的宏任务安排新的宏任务,则这些新任务将不会被此调用执行。
advanceTimersByTimeadvanceTimersByTime(n) 所有计时器都会提前n毫秒

advanceTimersByTime

  • useFakeTimes

对文件中的所有测试使用假计时器,直到原始计时器恢复为jest.useRealTimers()

1、这是一个全局性的操作。无论是在测试文件的顶部还是某个测试 case 中。
2、会被替换的方法包括Dateperformance.now()queueMicrotask()setImmediate()clearImmediate()setInterval()clearInterval()setTimeout(),clearTimeout()
3、在 Node 环境中process.hrtimeprocess.nextTick()
4、在 JSDOM 环境中requestAnimationFrame(),cancelAnimationFrame()requestIdleCallback(),cancelIdleCallback()也会被替换。

function timerGame(callback) {
    console.log('Ready....go!');
    setTimeout(() => {
        console.log('Times up -- stop!');
        callback && callback();
    }, 1000);
}
const callback = jest.fn(() => 'callback');
    jest.spyOn(global, 'setTimeout');
    timerGame(callback)
    expect(setTimeout).toHaveBeenCalledTimes(1); // ok
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000); // ok
    expect(callback).not.toBeCalled();  // ok

在此 demo 中,

  • 通过 spyOn 方法监听 setTimeout 方法 。

  • 通过 toHaveBeenCalledTimes 判断setTimeout方法是否被执行.

在这个例子中,我们只能确定setTimeout方法是否有被调用,但callback 方法是否在 1 秒之后调用我们并不知道,那么我们该如何确定我们的方法在 1秒之后被调用了呢?就需要用到runAllTimers 方法,我们往下看。

  • runAllTimers

执行所有挂起的宏任务和微任务

补充上面的测试案例

    jest.useFakeTimers();
    const callback = jest.fn(() => 'callback');
    jest.spyOn(global, 'setTimeout');
    timerGame(callback);

    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);

    expect(callback).not.toBeCalled();

    jest.runAllTimers();
    expect(callback).toBeCalled(); // ok
  • runOnlyPendingTimers
function timerGame(callback) {
    console.log('Ready....go!');
    setTimeout(() => {
        console.log('Times up -- stop!');
        callback && callback();
        timerGame(callback)
    }, 1000);
}

如果我们的函数中存在递归调用的情况,在运行测试 case 的时候就会不断的执行。而我们的目标只要确定callback是否有被执行, 只需要测试一次即可。改写我们的测试 case 。

    jest.useFakeTimers();
    const callback = jest.fn(() => 'callback');
    jest.spyOn(global, 'setTimeout');
    timerGame(callback);

    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);

    expect(callback).not.toBeCalled();

    jest.runOnlyPendingTimers();
    expect(callback).toBeCalled(); // ok
  • advanceTimersByTime
    jest.useFakeTimers();
    const callback = jest.fn(() => 'callback');
    jest.spyOn(global, 'setTimeout');
    timerGame(callback);

    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);

    expect(callback).not.toBeCalled();

    //jest.runAllTimers();
    //jest.advanceTimersByTime();
    expect(callback).toBeCalled(); // ok

在这里我们不再使用 runAllTimes,而是通过 advanceTimersByTime来提前执行callback

详细请看: jestjs.io/docs/jest-o…

喜欢就留个赞再走吧~