MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript测试框架Jest的使用与原理

2023-01-242.6k 阅读

一、Jest 基础使用

1.1 安装 Jest

在开始使用 Jest 之前,首先需要在项目中安装它。如果项目使用的是 npm,在项目根目录下执行以下命令:

npm install --save-dev jest

若是使用 yarn,则执行:

yarn add --dev jest

1.2 第一个 Jest 测试

假设我们有一个简单的 JavaScript 函数,用于计算两个数的和。在 src 目录下创建 add.js 文件,内容如下:

function add(a, b) {
    return a + b;
}
module.exports = add;

接下来,在 __tests__ 目录(Jest 默认约定的测试目录)下创建 add.test.js 文件,编写测试代码:

const add = require('../src/add');

test('adds 1 + 2 to equal 3', () => {
    expect(add(1, 2)).toBe(3);
});

在上述代码中:

  • require('../src/add') 引入了要测试的 add 函数。
  • test 是 Jest 提供的全局函数,用于定义一个测试用例。它接受两个参数,第一个参数是测试用例的描述,第二个参数是测试逻辑。
  • expect 用于创建一个期望,toBe 是 Jest 提供的断言方法,用于判断实际结果是否等于预期结果。

package.json 文件中,添加测试脚本:

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

然后执行 npm testyarn test,就可以运行测试用例,Jest 会输出测试结果。

1.3 断言

Jest 提供了丰富的断言方法,常用的有:

  • toBe:用于比较基本数据类型的值是否相等,它使用的是严格相等(===)。例如:
test('two plus two is four', () => {
    expect(2 + 2).toBe(4);
});
  • toEqual:用于比较对象或数组是否相等。它会递归地比较对象或数组的每一个属性和元素。例如:
test('objects are deep equal', () => {
    const obj1 = { a: 1, b: { c: 2 } };
    const obj2 = { a: 1, b: { c: 2 } };
    expect(obj1).toEqual(obj2);
});
  • toBeTruthytoBeFalsy:用于判断值是否为真值或假值。例如:
test('true is truthy', () => {
    expect(true).toBeTruthy();
});

test('false is falsy', () => {
    expect(false).toBeFalsy();
});
  • toBeNulltoBeUndefined:分别用于判断值是否为 nullundefined。例如:
test('null is null', () => {
    expect(null).toBeNull();
});

test('undefined is undefined', () => {
    expect(undefined).toBeUndefined();
});
  • toBeGreaterThantoBeLessThan:用于比较数字大小。例如:
test('2 is less than 3', () => {
    expect(2).toBeLessThan(3);
});

test('3 is greater than 2', () => {
    expect(3).toBeGreaterThan(2);
});
  • toMatch:用于字符串匹配正则表达式。例如:
test('string contains "hello"', () => {
    const str = 'hello world';
    expect(str).toMatch(/hello/);
});
  • toContain:用于数组是否包含某个元素。例如:
test('array contains "apple"', () => {
    const fruits = ['apple', 'banana', 'cherry'];
    expect(fruits).toContain('apple');
});
  • toThrow:用于测试函数是否抛出特定错误。例如:
function throwError() {
    throw new Error('This is an error');
}

test('function throws error', () => {
    expect(throwError).toThrow('This is an error');
});

1.4 测试异步代码

在 JavaScript 开发中,异步操作非常常见,Jest 提供了几种方式来测试异步代码。

  • 使用回调函数:假设我们有一个异步函数 fetchData,它接受一个回调函数,在数据获取完成后调用回调。
function fetchData(callback) {
    setTimeout(() => {
        callback('Data fetched');
    }, 100);
}

测试代码如下:

test('fetch data asynchronously', done => {
    fetchData(data => {
        expect(data).toBe('Data fetched');
        done();
    });
});

在上述测试中,done 是 Jest 提供的一个函数,用于告诉 Jest 异步操作已经完成。如果不调用 done,Jest 会认为测试超时。

  • 使用 Promise:如果异步函数返回一个 Promise,测试代码可以这样写:
function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Data fetched');
        }, 100);
    });
}

test('fetch data asynchronously with Promise', () => {
    return fetchData().then(data => {
        expect(data).toBe('Data fetched');
    });
}

也可以使用 async/await 语法,使代码更简洁:

test('fetch data asynchronously with async/await', async () => {
    const data = await fetchData();
    expect(data).toBe('Data fetched');
});
  • 测试拒绝的 Promise:如果异步函数返回的 Promise 可能会被拒绝,使用 rejects 断言。例如:
function fetchData() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error('Data fetch failed'));
        }, 100);
    });
}

test('fetch data fails asynchronously', () => {
    return expect(fetchData()).rejects.toThrow('Data fetch failed');
});

二、Jest 配置

2.1 Jest 配置文件

Jest 的配置可以通过 jest.config.js 文件进行。在项目根目录下创建该文件,以下是一个基本的配置示例:

module.exports = {
    preset: 'ts-jest', // 如果项目使用 TypeScript,指定预设为 ts-jest
    testEnvironment: 'jsdom', // 设置测试环境,jsdom 模拟浏览器环境
    testMatch: ['**/__tests__/**/*.test.js', '**/?(*.)+(spec|test).js'], // 匹配测试文件的模式
    moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx'], // 模块文件扩展名
    coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], // 忽略哪些路径的文件进行覆盖率计算
    collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'], // 从哪些文件收集覆盖率信息
    setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'] // 在测试环境加载后执行的文件
};

2.2 预设(Presets)

预设是一组 Jest 配置的集合,可以快速应用常用的配置。例如,@jest/preset - react 是用于 React 项目的预设,它包含了对 JSX 的支持、测试环境设置等。 要使用预设,在 jest.config.js 中指定 preset 字段:

module.exports = {
    preset: '@jest/preset - react'
};

2.3 测试环境(Test Environments)

Jest 支持多种测试环境,如 node(Node.js 环境)、jsdom(模拟浏览器环境)。

  • 使用 jsdomjsdom 是一个流行的 JavaScript 实现的 DOM 标准,在前端项目中经常使用。要使用 jsdom 环境,在 jest.config.js 中设置:
module.exports = {
    testEnvironment: 'jsdom'
};

jsdom 环境中,你可以使用一些 DOM 相关的操作,例如:

test('jsdom can create elements', () => {
    const div = document.createElement('div');
    expect(div.tagName).toBe('DIV');
});
  • 使用 node:如果项目是基于 Node.js 的后端项目,使用 node 环境更合适。在 jest.config.js 中设置:
module.exports = {
    testEnvironment: 'node'
};

node 环境中,你可以使用 Node.js 的全局变量和模块,例如 process

2.4 测试匹配模式(Test Match Patterns)

testMatch 配置项用于指定 Jest 应该查找哪些文件作为测试文件。默认情况下,Jest 会查找 __tests__ 目录下的 .test.js.spec.js 文件。 如果你的测试文件有不同的命名约定或位置,可以修改 testMatch。例如,如果你想在 tests 目录下查找所有以 .test.ts 结尾的文件:

module.exports = {
    testMatch: ['**/tests/**/*.test.ts']
};

2.5 模块文件扩展名(Module File Extensions)

moduleFileExtensions 配置项告诉 Jest 在解析模块时应该尝试哪些文件扩展名。默认值为 ['js', 'json']。 如果项目使用了其他文件扩展名,如 jsxtstsx 等,需要添加到这个数组中。例如:

module.exports = {
    moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx']
};

2.6 覆盖率(Coverage)

Jest 可以生成代码覆盖率报告,帮助你了解代码中有多少部分被测试覆盖。

  • 基本配置:在 jest.config.js 中,可以通过 collectCoverage 开启覆盖率收集,通过 coveragePathIgnorePatterns 忽略某些路径的文件,通过 collectCoverageFrom 指定从哪些文件收集覆盖率信息。例如:
module.exports = {
    collectCoverage: true,
    coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
    collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts']
};
  • 覆盖率报告类型:Jest 支持多种覆盖率报告类型,如 texthtmllcov 等。可以通过 coverageReporters 配置项指定。例如,要生成 HTML 和文本格式的覆盖率报告:
module.exports = {
    coverageReporters: ['html', 'text']
};

执行测试后,会在项目根目录下生成 coverage 目录,其中包含 HTML 格式的覆盖率报告,在终端会输出文本格式的覆盖率信息。

三、Jest 高级特性

3.1 模拟(Mocks)

在测试中,经常需要模拟一些外部依赖,以隔离被测试的代码。Jest 提供了强大的模拟功能。

  • 手动模拟:假设我们有一个模块 utils.js,其中有一个函数 fetchDataFromAPI 用于从 API 获取数据。
// utils.js
function fetchDataFromAPI() {
    // 实际的 API 调用逻辑
    return 'Real data from API';
}
module.exports = {
    fetchDataFromAPI
};

在测试中,我们可以手动创建一个模拟模块来替换真实的 utils.js。在 __mocks__ 目录(与被模拟模块同级)下创建 utils.js 文件:

// __mocks__/utils.js
function fetchDataFromAPI() {
    return 'Mocked data';
}
module.exports = {
    fetchDataFromAPI
};

在测试文件中,使用 jest.mock 来启用模拟:

const { fetchDataFromAPI } = require('../utils');
jest.mock('../utils');

test('uses mocked function', () => {
    const result = fetchDataFromAPI();
    expect(result).toBe('Mocked data');
});
  • 自动模拟:Jest 也可以自动生成模拟,它会保留原始模块的所有导出,但会将函数替换为 Jest 模拟函数。例如:
const { fetchDataFromAPI } = require('../utils');
jest.mock('../utils');

test('uses auto - mocked function', () => {
    const result = fetchDataFromAPI();
    expect(fetchDataFromAPI).toHaveBeenCalled();
    expect(result).toBe(undefined); // 自动模拟函数默认返回 undefined
});
  • 模拟函数的断言:对于模拟函数,可以使用一些断言方法,如 toHaveBeenCalled(判断函数是否被调用)、toHaveBeenCalledTimes(判断函数被调用的次数)、toHaveBeenCalledWith(判断函数是否以特定参数被调用)等。例如:
const { add } = require('../mathUtils');
jest.mock('../mathUtils');

test('add function is called correctly', () => {
    add(2, 3);
    expect(add).toHaveBeenCalled();
    expect(add).toHaveBeenCalledTimes(1);
    expect(add).toHaveBeenCalledWith(2, 3);
});

3.2 快照测试(Snapshot Testing)

快照测试是一种用于验证 UI 组件或其他数据结构输出的方法。Jest 会在第一次运行测试时生成一个快照文件,后续运行测试时会将新的输出与快照文件进行比较。 假设我们有一个简单的 React 组件 Button.js

import React from'react';

const Button = ({ text }) => {
    return <button>{text}</button>;
};

export default Button;

测试文件 Button.test.js

import React from'react';
import { render } from '@testing-library/react';
import Button from './Button';

test('Button snapshot', () => {
    const { container } = render(<Button text="Click me" />);
    expect(container).toMatchSnapshot();
});

第一次运行测试时,Jest 会在 __snapshots__ 目录下生成一个 Button.test.js.snap 文件,内容如下:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Button snapshot 1`] = `
<button>
    Click me
</button>
`;

后续运行测试时,如果 Button 组件的输出发生变化,Jest 会提示快照不匹配,并显示新旧快照的差异,方便开发者确认是否是预期的变化。如果是预期变化,可以更新快照,Jest 会重新生成快照文件。

3.3 测试钩子(Test Hooks)

Jest 提供了一些测试钩子函数,用于在测试前后执行一些操作。

  • beforeEach 和 afterEachbeforeEach 会在每个测试用例执行前执行,afterEach 会在每个测试用例执行后执行。例如:
let counter = 0;

beforeEach(() => {
    counter++;
});

afterEach(() => {
    counter--;
});

test('test 1', () => {
    expect(counter).toBe(1);
});

test('test 2', () => {
    expect(counter).toBe(1);
});

在上述代码中,beforeEach 每次测试前将 counter 加 1,afterEach 每次测试后将 counter 减 1,确保每个测试用例执行时 counter 的初始值为 1。

  • beforeAll 和 afterAllbeforeAll 会在所有测试用例执行前执行一次,afterAll 会在所有测试用例执行后执行一次。例如:
let data;

beforeAll(() => {
    data = { message: 'Initialized data' };
});

afterAll(() => {
    data = null;
});

test('test data exists', () => {
    expect(data).not.toBeNull();
});

在上述代码中,beforeAll 在所有测试用例前初始化 dataafterAll 在所有测试用例后将 data 置为 null

四、Jest 原理剖析

4.1 测试运行流程

Jest 的测试运行流程主要包括以下几个步骤:

  • 初始化:Jest 首先读取配置文件 jest.config.js,确定测试环境、测试文件匹配模式、模块解析规则等配置信息。然后,它会初始化测试框架,包括加载预设、设置全局变量等。
  • 收集测试文件:根据 testMatch 配置项,Jest 在项目目录中查找符合条件的测试文件。它会递归地遍历目录,将所有匹配的文件收集起来。
  • 加载测试文件:Jest 使用自己的模块加载器来加载测试文件。在加载过程中,会处理模块的依赖关系,确保所有依赖的模块都被正确加载。如果项目使用了 Babel 或 TypeScript 等工具进行编译,Jest 会通过相应的预设(如 @babel/preset - jestts - jest)对测试文件进行预处理。
  • 执行测试用例:Jest 将加载的测试文件中的测试用例按照一定顺序(通常是文件顺序和测试用例定义顺序)执行。对于每个测试用例,Jest 会创建一个新的测试上下文,其中包含测试环境、全局变量等。在执行测试用例时,Jest 会运行测试逻辑,并根据断言结果判断测试是否通过。
  • 生成测试报告:测试用例执行完成后,Jest 会收集所有测试用例的结果,生成测试报告。报告内容包括通过的测试用例数量、失败的测试用例数量、测试覆盖率等信息。如果启用了特定的覆盖率报告类型(如 htmllcov 等),Jest 还会生成相应格式的覆盖率报告。

4.2 断言实现原理

Jest 的断言方法是基于 expect 函数实现的。expect 函数接受一个值作为参数,并返回一个包含各种断言方法的对象。例如,expect(2 + 2).toBe(4) 中,expect(2 + 2) 返回一个对象,该对象上有 toBe 方法。 Jest 的断言方法内部使用了 JavaScript 的 Object.defineProperty 来定义这些断言方法。当调用 toBe 等断言方法时,会在内部执行相应的比较逻辑,并根据比较结果抛出错误或返回成功。例如,toBe 方法内部使用严格相等(===)进行比较:

function toBe(expected) {
    if (this.actual!== expected) {
        throw new Error(`Expected ${this.actual} to be ${expected}`);
    }
    return true;
}

这里的 this.actualexpect 函数传入的值。通过这种方式,Jest 实现了简洁且易于使用的断言语法。

4.3 模拟实现原理

Jest 的模拟功能主要通过替换模块的导出实现。当使用 jest.mock(moduleName) 时,Jest 会根据配置查找对应的模拟模块(手动模拟或自动模拟)。

  • 手动模拟:Jest 会直接使用 __mocks__ 目录下对应的模拟模块替换真实模块的导出。在加载模块时,Jest 会优先加载模拟模块,从而实现对模块功能的替换。
  • 自动模拟:Jest 会创建一个模拟对象,该对象包含原始模块的所有导出,但将函数替换为 Jest 模拟函数。Jest 模拟函数是一个特殊的函数,它可以记录函数的调用情况(如调用次数、调用参数等),并可以设置返回值或抛出错误。自动模拟函数的实现原理是通过 JavaScript 的函数封装和属性设置,例如:
function mockFunction() {
    mockFunction.callCount++;
    mockFunction.mock.calls.push(arguments);
    return mockFunction._defaultReturnValue;
}
mockFunction.callCount = 0;
mockFunction.mock = { calls: [] };
mockFunction._defaultReturnValue = undefined;

通过这种方式,Jest 实现了对模块中函数的模拟和调用记录功能。

4.4 快照测试原理

快照测试的原理是基于文件比较。当第一次运行包含快照测试的测试用例时,Jest 会将测试输出(如 React 组件的渲染结果)序列化为一个字符串,并将其写入到 __snapshots__ 目录下对应的快照文件中。 在后续运行测试时,Jest 会再次生成测试输出的序列化字符串,并与快照文件中的内容进行比较。如果两者相同,测试通过;如果不同,Jest 会提示快照不匹配,并显示新旧快照的差异。 Jest 使用了一些算法来优化快照比较,例如对字符串进行差异分析,只显示有变化的部分,方便开发者查看和定位问题。同时,Jest 提供了更新快照的机制,当确认变化是预期的时,可以通过命令(如 jest --updateSnapshot)更新快照文件,使其与新的测试输出一致。