全面掌握 Jest:从零开始的测试指南(上篇)

随着JavaScript在前后端开发中的广泛应用,测试已成为保证代码质量的关键环节。

为什么需要单元测试

在我们的开发过程中,经常需要定义一些算法函数,例如将接口返回的数据转换成UI组件所需的格式。为了校验这些算法函数的健壮性,部分开发同学可能会手动定义几个输入样本进行初步校验,一旦校验通过便不再深究。

然而,这样的做法可能会带来一些潜在的问题。首先,边界值的情况往往容易被忽视,导致校验不够全面,增加了系统出现故障的风险。其次,随着需求的变化和演进,算法函数可能需要进行优化和扩展。如果前期的校验工作不够彻底,不了解现有函数覆盖的具体场景,就可能导致在后续的修改中引入新的问题。

单元测试可以有效地解决上述问题。在定义算法函数时,同步创建单元测试文件,并将可能出现的各种场景逐一列举。如果单元测试未能通过,项目在编译时会直接报错,从而能够及时发现并针对性地解决问题。此外,当后续有新同学加入并需要扩展功能时,他们不仅需要在原有的单元测试基础上添加新的测试用例,还能确保新功能的正确性,同时保障原有功能的正常运行。

自定义测试逻辑

在开始使用工具来进行单元测试之前,我们可以先自定义一个工具函数供测试使用。

例如,我们有一个 add 函数,期望它能够正确计算两个数的和,并验证其结果是否符合预期。比如,我们希望验证 2 + 3的结果是否等于 5 ,可以使用 expect(add(2, 3)).toBe(5) 这样的代码来实现。为此,我们可以自行定义一个expect 函数,使其具备类似Jest中 expect 函数的功能

function add(a, b) { return a + b; }
function expect(result) {
  return {
    toBe(value) {
      if (result === value) {
        console.log("验证成功");
      } else {
        throw new Error(`执行错误:${result} !== ${value}`);
      }
    },
  };
}

// 调用示例
try {
  expect(add(2, 3)).toBe(5);  // 输出:"验证成功"
  expect(add(2, 3)).toBe(6);  // 抛出错误
} catch (err) {
  console.error(err.message);  // 输出:"执行错误:5 !== 6"
}

为了使测试更具描述性和可读性,我们可以进一步增强我们的测试逻辑。例如,我们可以添加一个 test 函数,用于描述测试的目的,并在测试失败时提供更详细的错误信息。

function test(description, fn) {
  try {
    fn();
    console.log(`测试通过: ${description}`);
  } catch (err) {
    console.error(`测试失败: ${description} - ${err.message}`);
  }
}
// 调用示例
test("验证 2 + 3 是否等于 5", () => {
  expect(add(2, 3)).toBe(5);
});
test("验证 2 + 3 是否等于 6", () => {
  expect(add(2, 3)).toBe(6);
});

通过这种方式,我们模拟了一个简单的测试用例,其中 testexpect 函数类似于Jest中的功能。然而,我们的自定义版本相对简陋,缺乏 Jest 提供的丰富功能。

Jest

通过上述示例,我们可以了解到编写测试的基本思路和方法。然而,在实际开发中,我们需要一个功能更加强大、易用性更高的测试工具。Jest 正是这样一个工具,它不仅提供了丰富的匹配器(如toBe、toEqual等),还支持异步测试Mock函数Snapshot测试 等功能。

引入 Jest 的依赖后,我们可以直接使用其内置的 testexpect 函数,从而大大提高测试的效率和准确性。Jest 的强大之处在于它能够帮助我们全面地覆盖各种测试场景,并提供详细的错误报告,使我们能够快速定位和解决问题。

初始化

首先,我们通过 npm install jest -D 安装 Jest 依赖,然后执行 npx jest --init。此时,命令行工具会出现一系列交互式问答,询问你是否要为 Jest 添加名为 test 的脚本指令、是否使用 TypeScript 作为配置文件、测试用例执行环境、是否需要代码覆盖率测试报告、生成测试报告的平台的编译器以及是否需要在每次测试用例执行前重置 Mock 函数状态。

完成所有问答后,Jest 会修改 package.json 文件,并生成jest.config.js配置文件。在执行测试用例时,将依据这些配置项进行。

我们创建一个 math.test.js 文件,并将之前的测试代码放入其中

function add(a, b) {
  return a + b;
}
test("测试 add 函数", () => {
  expect(add(2, 3)).toBe(5);
});

通过 npm run test 执行 Jest 运行指令,可以在命令行工具查看详细的测试信息,包括哪个文件的哪条测试用例的状态,以及简易的测试覆盖率报告。

在实际使用场景中,add 函数通常定义在项目文件中,并通过 ES 模块化 (export 和 import) 方式导出和导入。默认情况下,Jest 并不支持 ES 模块化语法,因此我们需要通过 Babel 进行配置。

首先,执行以下命令安装 Babel 及其核心库和预设

npm install @babel/core @babel/preset-env --save-dev

然后,创建babel.config.js文件并定义配置

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};

接着,将 add 函数移到 math.js 文件中,并使用 export 导出

// math.js
export function add(a, b) {
  return a + b;
}

最后,在 math.test.js 文件中使用 import 导入

// math.test.js
import { add } from './math';
test("测试 add 函数", () => {
  expect(add(2, 3)).toBe(5);
});

通过以上步骤,你就完成了使用 Jest 执行 ES 模块化代码的环境初始化。

匹配器

Jest 中最常用的功能之一就是匹配器。在前面进行测试时,我们就接触过 toBe 这一匹配器,它用于判断值是否相等。除此之外,还有许多其他类型的匹配器。

值相等

判断值相等有两种匹配器:toBetoEqual。对于基本数据类型(如字符串、数字、布尔值),两者的使用效果相同。但对于引用类型(如对象和数组),toBe 只有在两个引用指向同一个内存地址时才会返回 true

const user = { name: "alice" };
const info = { name: "alice" };

test("toEqual", () => {
  expect(info).toEqual(user); // 通过,两者结构相同
});
test("toBe", () => {
  expect(info).toBe(user); // 不通过,两者的引用地址不同
});
是否有值

存在 toBeNulltoBeUndefinedtoBeDefined 匹配器来分别判断值是否为 null、未定义或已定义。

test("toBeNull", () => {
  expect(null).toBeNull();
  expect(0).toBeNull(); // 不通过
  expect("hello").toBeNull(); // 不通过
  expect(undefined).toBeBull(); // 不通过
});

test("toBeUnDefined", () => {
  expect(null).toBeUndefined(); // 不通过
  expect(0).toBeUndefined(); // 不通过
  expect("hello").toBeUndefined(); // 不通过
  expect(undefined).toBeUndefined();
});

test("toBeDefined", () => {
  expect(null).toBeDefined();
  expect(0).toBeDefined();
  expect("hello").toBeDefined();
  expect(undefined).toBeDefined(); // 不通过
});
是否为真

toBeTruthy 用于判断值是否为真,toBeFalsy 用于判断值是否为假,not 用于取反。

test("toBeTruthy", () => {
  expect(null).toBeTruthy(); // 不通过
  expect(0).toBeTruthy(); // 不通过
  expect(1).toBeTruthy();
  expect("").toBeTruthy(); // 不通过
  expect("hello").toBeTruthy();
  expect(undefined).toBeTruthy(); // 不通过
});
test("toBeFalsy", () => {
  expect(null).toBeFalsy();
  expect(0).toBeFalsy();
  expect(1).toBeFalsy(); // 不通过
  expect("").toBeFalsy();
  expect("hello").toBeFalsy(); // 不通过
  expect(undefined).toBeFalsy();
});
test("not", () => {
  expect(null).not.toBeTruthy();
  expect("hello").not.toBeTruthy(); // 不通过
});
数字比较

toBeGreaterThan 用于判断是否大于某个数值,toBeLessThan 用于判断是否小于某个数值,toBeGreaterThanOrEqual 用于判断是否大于或等于某个数值,toBeCloseTo 用于判断是否接近某个数值(差值 < 0.005)。

test("toBeGreaterThan", () => {
  expect(9).toBeGreaterThan(5);
  expect(5).toBeGreaterThan(5); // 不通过
  expect(1).toBeGreaterThan(5); // 不通过
});

test("toBeLessThan", () => {
  expect(9).toBeLessThan(5); // 不通过
  expect(5).toBeLessThan(5); // 不通过
  expect(1).toBeLessThan(5);
});

test("toBeGreaterThanOrEqual", () => {
  expect(9).toBeGreaterThanOrEqual(5);
  expect(5).toBeGreaterThanOrEqual(5);
  expect(1).toBeGreaterThanOrEqual(5); // 不通过
});

test("toBeCloseTo", () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect(1 + 2).toBeCloseTo(3);
  expect(0.1 + 0.2).toBeCloseTo(0.4); // 不通过
});
字符串相关

toMatch 用于判断字符串是否包含指定子字符串,部分包含即可。

test("toMatch", () => {
  expect("alice").toMatch("alice"); // 通过
  expect("alice").toMatch("lice"); // 通过
  expect("alice").toMatch("al"); // 通过
});
数组相关

toContain 用于判断数组是否包含指定元素,类似于 JavaScript 中的 includes 方法。

test("toContain", () => {
  expect(['banana', 'apple', 'orange']).toContain("apple");
  expect(['banana', 'apple', 'orange']).toContain("app"); // 不通过
});
error相关

toThrow 用于判断函数是否抛出异常,并可以指定抛出异常的具体内容。

test("toThrow", () => {
  const throwNewErrorFunc = () => {
    throw new TypeError("this is a new error");
  };
  expect(throwNewErrorFunc).toThrow();
  expect(throwNewErrorFunc).toThrow("new error");
  expect(throwNewErrorFunc).toThrow("TypeError"); // 不通过
});

以上就是各类型常用的匹配器。

命令行工具

package.json 中配置 script 指令,可以使 .test.js 文件在修改时实时自动执行测试用例。

"scripts": {
   "jest": "jest --watchAll"
},

在命令行中,你会实时看到当前测试用例的执行结果。同时,Jest 还提供了一些快捷配置,按下 w 键即可查看具体有哪些指令。

主要有以下几种类型:

f 模式
在所有测试用例中,只执行上一次失败的测试用例。即使其他测试用例的内容有修改,也不会被执行。

o 模式
只执行修改过的测试用例。这个功能需要配合 Git 来实现,根据本次相对于上次 Git 仓库的更改。这种模式还可以通过配置 script 指令来实现,即:

"script": {
"test": "jest --watch"
}

p模式
当使用 --watchAll 时,修改一个文件的代码后,所有的测试用例都会执行。进入 p 模式后,可以输入文件名 matchersFile,此时修改任何文件只会去查找包含 matchersFile 的文件并执行。

t模式
输入测试用例名称,匹配 test 函数的第一个参数。匹配成功后即执行该测试用例。

q模式
退出实时代码检测。

通过不同的指令,你可以更有针对性地检测测试用例。

钩子函数

在 Jest 中,describe 函数用于将一系列相关的测试用例(tests)组合在一起,形成一个描述性的测试块。它接受两个参数:第一个参数是一个字符串,用于描述测试块的主题;第二个参数是一个函数,包含一组测试用例。

即使没有显式定义 describe 函数,每个测试文件也会在最外层默认加上一层 describe 包裹。

在 describe 组成的每个块中,存在一些钩子函数,贯穿测试用例的整个过程。这些钩子函数主要用于测试用例执行之前的准备工作或之后的清理工作。

常用的钩子函数
  • beforeAll 函数在一个 describe 块开始之前执行一次
  • afterAll 函数在一个 describe 块结束之后执行一次
  • beforeEach 函数在每个测试用例之前执行
  • afterEach 在每个测试用例之后执行
示例代码

下面的示例代码展示了如何使用这些钩子函数:

describe("测试是否有值", () => {
  beforeAll(() => {
    console.log("beforeAll");
  });
  afterAll(() => {
    console.log("afterAll");
  });
  beforeEach(() => {
    console.log("beforeEach");
  });
  describe("toBeNull", () => {
    beforeAll(() => {
      console.log("toBeNull beforeAll");
    });
    afterAll(() => {
      console.log("toBeNull afterAll");
    });
    beforeEach(() => {
      console.log("toBeNull beforeEach");
    });
    test("toBeNull", () => {
      expect(null).toBeNull();
    });
  });
});
输出顺序

当运行上述测试用例时,输出的顺序如下:

beforeAll
toBeNull beforeAll
beforeEach
toBeNull beforeEach
toBeNull afterAll
afterAll

通过使用这些钩子函数,你可以更好地管理测试用例的生命周期,确保每次测试都从一个干净的状态开始,并在测试结束后清理掉产生的副作用。

在这一篇测试指南中,我们介绍了Jest 的背景、如何初始化项目、常用的匹配器语法、钩子函数。下一篇篇将继续深入探讨 Jest 的高级特性,包括 Mock 函数、异步请求的处理、Mock 请求的模拟、类的模拟以及定时器的模拟、snapshot 的使用。通过这些技术,我们将能够更高效地编写和维护测试用例,尤其是在处理复杂异步逻辑和外部依赖时。