4、Jest 核心功能:匹配器、钩子函数与 Mock 模拟

好,咱们今天来啃 Jest 最核心的三个功能。说实话,这三个东西你玩明白了,前端自动化测试基本就入门了。我在团队里带新人时,发现很多人卡就卡在「不知道用什么断言」和「不知道怎么模拟依赖」上。今天咱们一次性说透。

4.1 匹配器:你的断言工具箱

匹配器(Matchers)说白了就是 Jest 提供的一堆断言方法。你想想看,测试的本质是什么?就是「我期望这个值是某个结果」。匹配器就是帮你表达这个「期望」的工具。

4.1.1 toBe 与 toEqual:最常用的两个

toBe 用的是 Object.is(),比较的是引用。而 toEqual 会递归检查对象的每个字段。我刚开始写测试时犯过一个低级错误:用 toBe 比较两个结构相同的对象,结果测试一直挂。后来才反应过来——引用不同啊!

test('toBe 与 toEqual 的区别', () => {
  const obj1 = { name: 'Jest' };
  const obj2 = { name: 'Jest' };

  // toBe 比较引用,这里会失败
  // expect(obj1).toBe(obj2); // ❌

  // toEqual 比较值,这里通过
  expect(obj1).toEqual(obj2); // ✅

  // 基本类型用 toBe 没问题
  expect(2 + 2).toBe(4);
});

核心原则:基本类型用 toBe,对象/数组用 toEqual。记住这条,能省掉你 80% 的调试时间。

4.1.2 toContain:数组与字符串的利器

这个匹配器我几乎每天都会用。检查数组里有没有某个元素,或者字符串里有没有某个子串,一行代码搞定。

test('toContain 的使用场景', () => {
  const shoppingList = ['牛奶', '面包', '鸡蛋'];

  expect(shoppingList).toContain('牛奶');
  expect(shoppingList).not.toContain('可乐');

  const message = 'Hello Jest World';
  expect(message).toContain('Jest');
});

小技巧:如果你要检查数组里是否包含某个对象,用 toContainEqual。它会做深度比较,而不是引用比较。

4.1.3 其他常用匹配器一览

匹配器 用途 示例
toBeNull() 只匹配 null expect(x).toBeNull()
toBeUndefined() 只匹配 undefined expect(x).toBeUndefined()
toBeTruthy() 匹配所有真值 expect(1).toBeTruthy()
toBeFalsy() 匹配所有假值 expect(0).toBeFalsy()
toMatch() 正则匹配字符串 expect('abc').toMatch(/bc/)
toThrow() 检查函数是否抛出异常 expect(fn).toThrow()

注意:toBeTruthytoBe(true) 不是一回事。前者只要值能转为 true 就通过,后者必须是严格的 true。我曾经在项目里用混了,导致一个本该失败的测试通过了,排查了半天才发现是匹配器用错了。

4.2 钩子函数:测试的生命周期管理

钩子函数解决的是「重复代码」的问题。你想想看,如果每个测试用例都要创建同一个对象、连接同一个数据库,那代码得多冗余?

4.2.1 beforeEach 与 afterEach

这两个是最常用的。每个测试用例执行前后都会跑一次。我个人习惯把「数据准备」放在 beforeEach 里,把「清理工作」放在 afterEach 里。

let data;

beforeEach(() => {
  // 每个测试前都重新初始化
  data = { count: 0, items: [] };
  console.log('数据已重置');
});

afterEach(() => {
  // 每个测试后清理
  data = null;
  console.log('数据已清理');
});

test('数据初始状态', () => {
  expect(data.count).toBe(0);
});

test('添加数据', () => {
  data.items.push('新项目');
  expect(data.items).toHaveLength(1);
});

关键点:每个测试用例之间应该是隔离的。beforeEach 保证了每个用例拿到的都是「干净」的数据。我曾经见过一个项目,因为没在 beforeEach 里重置状态,导致测试用例之间互相影响,跑出来的结果时对时错,特别坑。

4.2.2 beforeAll 与 afterAll

这两个只执行一次。适合做「一次性」的初始化,比如连接数据库、读取配置文件。但要注意——如果某个测试用例修改了共享数据,后面的用例可能会受影响。

let dbConnection;

beforeAll(() => {
  // 只执行一次,比如建立数据库连接
  dbConnection = { connected: true };
  console.log('数据库已连接');
});

afterAll(() => {
  // 所有测试结束后关闭连接
  dbConnection = null;
  console.log('数据库已断开');
});

test('数据库连接状态', () => {
  expect(dbConnection.connected).toBe(true);
});

避坑指南:我曾经在 beforeAll 里初始化了一个全局计数器,结果多个测试用例都在修改它,最后测试结果完全不可预测。记住:beforeAll 适合「只读」的初始化,如果每个用例需要独立的数据,用 beforeEach

4.2.3 钩子的作用域

钩子可以放在 describe 块内部,只对该块内的测试生效。这个特性在组织大型测试文件时特别有用。

describe('用户模块', () => {
  let user;

  beforeEach(() => {
    user = { name: '张三', role: 'admin' };
  });

  test('用户有名字', () => {
    expect(user.name).toBe('张三');
  });

  describe('权限检查', () => {
    beforeEach(() => {
      // 这个钩子只对当前 describe 内的测试生效
      user.permissions = ['read', 'write'];
    });

    test('管理员有读写权限', () => {
      expect(user.permissions).toContain('write');
    });
  });
});

4.3 Mock 函数与模块模拟

Mock 是测试里最强大的功能之一。说白了,就是「我不想真的调用某个函数,但我需要知道它有没有被调用、被怎么调用的」。我在项目中遇到过很多次,测试一个模块时它依赖了第三方 API,总不能每次跑测试都真的去请求网络吧?这时候 Mock 就派上用场了。

4.3.1 创建 Mock 函数

jest.fn() 可以创建一个 Mock 函数。它能记录自己被调用的次数、参数、返回值等信息。

test('Mock 函数的基本用法', () => {
  const mockFn = jest.fn();

  mockFn('hello', 'world');
  mockFn('jest');

  // 检查调用次数
  expect(mockFn).toHaveBeenCalledTimes(2);

  // 检查某次调用的参数
  expect(mockFn).toHaveBeenCalledWith('hello', 'world');
  expect(mockFn).toHaveBeenLastCalledWith('jest');
});

4.3.2 模拟返回值

你可以让 Mock 函数返回你指定的值。这个在测试「依赖外部服务」的代码时特别有用。

test('模拟返回值', () => {
  const mockFn = jest.fn()
    .mockReturnValueOnce('第一次调用')
    .mockReturnValueOnce('第二次调用')
    .mockReturnValue('默认返回值');

  expect(mockFn()).toBe('第一次调用');
  expect(mockFn()).toBe('第二次调用');
  expect(mockFn()).toBe('默认返回值');
  expect(mockFn()).toBe('默认返回值');
});

实用技巧:mockReturnValueOnce 可以模拟「第一次调用返回 A,第二次返回 B」的场景。我在测试一个重试逻辑时就用到了这个,模拟前两次失败、第三次成功,完美验证了重试机制。

4.3.3 模块模拟

有时候你需要模拟整个模块。比如你的代码里用了 axios 发请求,测试时你肯定不想真的发出去。用 jest.mock() 可以自动模拟整个模块。

// 假设有一个 api.js 文件
// export const fetchData = () => axios.get('/api/data');

// 在测试文件中
jest.mock('./api');

import { fetchData } from './api';

test('模拟整个模块', async () => {
  // 设置模拟返回值
  fetchData.mockResolvedValue({ data: '模拟数据' });

  const result = await fetchData();
  expect(result.data).toBe('模拟数据');
  expect(fetchData).toHaveBeenCalledTimes(1);
});

核心思想:模块模拟让你能「隔离」被测试的代码。你只测试当前模块的逻辑,不关心它的依赖到底怎么实现。这就像修车时,你只检查发动机,不用管轮胎是不是原厂的。

4.3.4 模拟时间

这个功能我特别喜欢。测试里如果有 setTimeoutsetInterval,真的等时间过去太慢了。用 jest.useFakeTimers() 可以模拟时间流逝。

jest.useFakeTimers();

test('模拟定时器', () => {
  const callback = jest.fn();

  setTimeout(callback, 1000);

  // 时间还没到,callback 没被调用
  expect(callback).not.toHaveBeenCalled();

  // 快进时间
  jest.advanceTimersByTime(1000);

  // 现在 callback 被调用了
  expect(callback).toHaveBeenCalledTimes(1);
});

注意:用了 jest.useFakeTimers() 后,记得在 afterEach 里调用 jest.useRealTimers() 恢复真实时间。不然其他测试可能会受影响。我刚开始用的时候忘了恢复,结果其他测试里的定时器全都不走了,排查了好久才发现是这个问题。

4.4 小结

这三个核心功能,你掌握了就能写出一手漂亮的测试用例。匹配器帮你准确断言结果,钩子函数帮你管理测试生命周期,Mock 让你能隔离外部依赖。嗯,说白了,这就是 Jest 测试的「三板斧」。下一章咱们会把这些知识串起来,写一个完整的实战案例。