一文搞定jest单元测试

Jest 是一个由 Facebook 开发的 JavaScript 测试框架,它以其"零配置"的体验、强大的 mock 功能、内置的断言库、快照测试以及并行测试执行等特性,在前端社区广受欢迎,尤其是在 React 项目中。

Jest 单元测试核心知识

1. 什么是单元测试?

单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。在前端,这个"单元"通常可以是一个函数、一个类、一个模块或一个 UI 组件。

2. 为什么选择 Jest?

  • 零配置体验:对于许多 JavaScript 项目,Jest 可以开箱即用,无需复杂配置。
  • 快速高效:Jest 会并行运行测试,并优先运行之前失败的测试,以最大限度地提高性能。
  • 内置断言库:Jest 自带了丰富的断言方法(matchers),无需额外引入如 Chai 之类的断言库。
  • 强大的 Mock 功能:轻松模拟函数、模块和定时器,有效隔离测试单元。
  • 快照测试 (Snapshot Testing) :可以捕获大型对象或 UI 组件的结构,并在后续测试中进行比较,方便追踪意外的变更。
  • 代码覆盖率报告:内置支持生成代码覆盖率报告,帮助评估测试的完整性。
  • 良好的社区支持和文档:拥有庞大的用户群体和完善的官方文档。

3. Jest 的核心概念

  • describe(name, fn) : 创建一个块,将几个相关的测试组合在一起。

  • it(name, fn)test(name, fn) : 这是实际的测试用例。ittest 的别名。

  • expect(value) : 用于测试某个值。它通常与"匹配器"(matcher)函数一起使用来断言值的某些特性。

  • Matchers (匹配器) : 一系列用于 expect 的函数,如 toBe(), toEqual(), toHaveBeenCalled(), toMatchSnapshot() 等。

  • Setup 和 Teardown:

    • beforeEach(fn): 在 describe 块中的每个测试用例运行之前执行。
    • afterEach(fn): 在 describe 块中的每个测试用例运行之后执行。
    • beforeAll(fn): 在 describe 块中的所有测试用例运行之前执行一次。
    • afterAll(fn): 在 describe 块中的所有测试用例运行之后执行一次。
  • Mocking (模拟) :

    • jest.fn(): 创建一个模拟函数。
    • jest.mock('moduleName'): 模拟一个模块。
    • jest.spyOn(object, methodName): 监视对象上某个方法的调用,同时允许原始实现继续执行。
  • 异步测试 : Jest 支持多种方式测试异步代码,包括 Promises, async/await

Jest 工作流程

  1. 安装与配置:

    • 安装 Jest 及相关依赖(如 Babel 用于 ES6+ 和 JSX,ts-jest 用于 TypeScript,@testing-library/react 用于 React 组件测试)。
    • 创建 Jest 配置文件 (jest.config.js) 或在 package.json 中配置。
  2. 编写测试文件:

    • 通常测试文件与被测试的源文件放在一起(例如,component.jscomponent.test.js)或统一放在 __tests__ 目录下。
    • 测试文件名约定为 *.test.js, *.spec.js, *.test.ts, *.spec.ts, *.test.jsx, *.spec.jsx
  3. 编写测试用例:

    • 使用 describe 组织测试套件。
    • 使用 ittest 定义单个测试用例。
    • 在测试用例中,使用 expect 和匹配器进行断言。
    • 如果需要,使用 beforeEach, afterEach, beforeAll, afterAll 进行设置和清理。
    • 对外部依赖、API 调用等使用 Mock。
  4. 运行测试:

    • 通过 npm scripts (如 npm testyarn test) 运行 Jest。
    • Jest 会查找并执行所有测试文件。
  5. 查看结果与调试:

    • Jest 会在控制台输出测试结果,包括通过的测试、失败的测试以及错误信息。
    • 对于失败的测试,根据错误信息调试代码或测试用例。
    • 使用 jest --watch 进入观察模式,文件变更时自动重新运行相关测试。
    • 使用 jest --coverage 生成代码覆盖率报告。
  6. 快照测试 (可选) :

    • 对于 UI 组件或大型数据结构,可以使用快照测试。
    • 首次运行时,Jest 会生成快照文件。
    • 后续运行时,Jest 会将当前结果与已保存的快照进行比较。
    • 如果存在差异且变更是预期的,可以使用 jest -u (或 jest --updateSnapshot) 更新快照。
  7. 集成到 CI/CD:

    • 将测试命令集成到持续集成/持续部署流程中,确保代码变更不会破坏现有功能。

详细代码示例

从简单的函数测试开始,逐步过渡到更复杂的场景,如异步代码、Mocking 和 React 组件测试。

环境准备

假设我们有一个 Node.js 项目。

  1. 初始化项目 (如果还没有的话):

    bash 复制代码
    mkdir jest-demo
    cd jest-demo
    npm init -y
  2. 安装 Jest:

    bash 复制代码
    npm install --save-dev jest
  3. 配置 package.json (添加 test script):

    json 复制代码
    {
      "name": "jest-demo",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "jest",
        "test:watch": "jest --watch",
        "test:coverage": "jest --coverage"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "jest": "^29.0.0" // 版本号可能不同
      }
    }

示例1: 测试简单的同步函数

假设我们有一个 utils/math.js 文件:

js 复制代码
// utils/math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
};

module.exports = { add, subtract, multiply, divide };

现在,我们为它编写测试文件 utils/math.test.js:

js 复制代码
// utils/math.test.js
const { add, subtract, multiply, divide } = require('./math');

// 使用 describe 对测试用例进行分组
describe('Math Utility Functions', () => {
  // 测试 add 函数
  describe('add function', () => {
    test('should correctly add two positive numbers', () => {
      expect(add(2, 3)).toBe(5); // toBe 用于精确匹配(===)
    });

    it('should correctly add a positive and a negative number', () => {
      expect(add(5, -2)).toBe(3);
    });

    it('should correctly add two negative numbers', () => {
      expect(add(-5, -2)).toBe(-7);
    });

    it('should correctly add zero', () => {
      expect(add(5, 0)).toBe(5);
      expect(add(0, 0)).toBe(0);
    });
  });

  // 测试 subtract 函数
  describe('subtract function', () => {
    test('should correctly subtract two numbers', () => {
      expect(subtract(5, 3)).toBe(2);
    });

    test('should handle negative results', () => {
      expect(subtract(3, 5)).toBe(-2);
    });
  });

  // 测试 multiply 函数
  describe('multiply function', () => {
    test('should correctly multiply two numbers', () => {
      expect(multiply(3, 4)).toBe(12);
    });

    test('should handle multiplication by zero', () => {
      expect(multiply(5, 0)).toBe(0);
    });
  });

  // 测试 divide 函数
  describe('divide function', () => {
    test('should correctly divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('should handle division resulting in a fraction', () => {
      expect(divide(5, 2)).toBe(2.5);
      // 对于浮点数,使用 toBeCloseTo 避免精度问题
      expect(divide(1, 3)).toBeCloseTo(0.333);
    });

    test('should throw an error when dividing by zero', () => {
      // 测试函数是否抛出错误
      expect(() => divide(10, 0)).toThrow();
      expect(() => divide(10, 0)).toThrow('Cannot divide by zero'); // 可以匹配错误信息
      // 也可以匹配错误对象的类型
      // expect(() => divide(10, 0)).toThrow(Error);
    });
  });
});

// 更多匹配器示例
describe('Other Matchers', () => {
  test('object assignment', () => {
    const data = { one: 1 };
    data['two'] = 2;
    // toEqual 递归比较对象或数组的每个字段
    expect(data).toEqual({ one: 1, two: 2 });
  });

  test('adding positive numbers is not zero', () => {
    for (let a = 1; a < 10; a++) {
      for (let b = 1; b < 10; b++) {
        // .not 修饰符
        expect(add(a, b)).not.toBe(0);
      }
    }
  });

  // 真值性
  test('null', () => {
    const n = null;
    expect(n).toBeNull();
    expect(n).toBeDefined();
    expect(n).not.toBeUndefined();
    expect(n).not.toBeTruthy();
    expect(n).toBeFalsy();
  });

  test('zero', () => {
    const z = 0;
    expect(z).not.toBeNull();
    expect(z).toBeDefined();
    expect(z).not.toBeUndefined();
    expect(z).not.toBeTruthy();
    expect(z).toBeFalsy();
  });

  // 数字比较
  test('two plus two', () => {
    const value = 2 + 2;
    expect(value).toBeGreaterThan(3);
    expect(value).toBeGreaterThanOrEqual(3.5);
    expect(value).toBeLessThan(5);
    expect(value).toBeLessThanOrEqual(4.5);

    // toBe 和 toEqual 对于数字是等价的
    expect(value).toBe(4);
    expect(value).toEqual(4);
  });

  // 字符串匹配
  test('there is no I in team', () => {
    expect('team').not.toMatch(/I/); // 正则表达式匹配
  });

  test('but there is a "stop" in Christoph', () => {
    expect('Christoph').toMatch(/stop/);
  });

  // 数组和可迭代对象
  test('the shopping list has milk on it', () => {
    const shoppingList = [
      'diapers',
      'kleenex',
      'trash bags',
      'paper towels',
      'milk',
    ];
    expect(shoppingList).toContain('milk');
    expect(new Set(shoppingList)).toContain('milk');
  });
});

运行 npm test,你应该能看到所有测试通过。

示例2: Setup 和 Teardown

假设我们需要在每个测试之前或之后执行一些通用操作。

js 复制代码
// setup-teardown.test.js

let sharedResource;

// 在所有测试开始前执行一次
beforeAll(() => {
  console.log('BeforeAll: Initializing shared resource...');
  sharedResource = { data: [], counter: 0 };
});

// 在每个测试开始前执行
beforeEach(() => {
  console.log('BeforeEach: Resetting resource for a new test...');
  sharedResource.data = ['initial item'];
  sharedResource.counter++; // 记录测试执行次数
});

// 在每个测试结束后执行
afterEach(() => {
  console.log('AfterEach: Cleaning up after a test...');
  // 假设这里有一些清理逻辑,比如清空数组
  sharedResource.data = [];
});

// 在所有测试结束后执行一次
afterAll(() => {
  console.log('AfterAll: Releasing shared resource...');
  sharedResource = null; // 释放资源
});

describe('Testing with Setup and Teardown', () => {
  test('first test using shared resource', () => {
    expect(sharedResource.data).toEqual(['initial item']);
    sharedResource.data.push('item from test 1');
    expect(sharedResource.data).toEqual(['initial item', 'item from test 1']);
    expect(sharedResource.counter).toBeGreaterThanOrEqual(1); // counter 会累加
  });

  test('second test using shared resource', () => {
    // 由于 beforeEach,data 会被重置
    expect(sharedResource.data).toEqual(['initial item']);
    sharedResource.data.push('item from test 2');
    expect(sharedResource.data).toEqual(['initial item', 'item from test 2']);
    expect(sharedResource.counter).toBeGreaterThanOrEqual(2);
  });
});

// describe 块可以嵌套,setup/teardown 也有作用域
describe('Scoped Setup and Teardown', () => {
  let scopedResource;

  beforeAll(() => {
    console.log('Scoped BeforeAll');
    scopedResource = 'scoped setup';
  });

  beforeEach(() => {
    console.log('Scoped BeforeEach');
    // 外部的 beforeEach 仍然会执行
  });

  test('test within scoped describe', () => {
    expect(scopedResource).toBe('scoped setup');
    expect(sharedResource.data).toEqual(['initial item']); // 外部 beforeEach 依然生效
  });

  afterAll(() => {
    console.log('Scoped AfterAll');
  });
});

// 测试执行顺序:
// 外部 beforeAll
// 外部 beforeEach
//   测试1
// 外部 afterEach
// 外部 beforeEach
//   测试2
// 外部 afterEach
// 内部 (Scoped) beforeAll
// 外部 beforeEach
// 内部 (Scoped) beforeEach
//   内部测试
// 内部 (Scoped) afterEach (如果定义了)
// 外部 afterEach
// 内部 (Scoped) afterAll
// 外部 afterAll

示例3: 测试异步代码

假设我们有一个函数从模拟的 API 获取数据:

js 复制代码
// services/dataFetcher.js
const fetchDataWithCallback = (callback) => {
  setTimeout(() => {
    const data = { user: 'John Doe', id: 1 };
    callback(null, data); // 模拟成功回调
    // callback(new Error('Network error')); // 模拟错误回调
  }, 100);
};

const fetchDataWithPromise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.2; // 80% 成功率
      if (success) {
        resolve({ user: 'Jane Doe', id: 2 });
      } else {
        reject(new Error('Failed to fetch data'));
      }
    }, 100);
  });
};

module.exports = { fetchDataWithCallback, fetchDataWithPromise };

测试文件 services/dataFetcher.test.js:

js 复制代码
// services/dataFetcher.test.js
const { fetchDataWithCallback, fetchDataWithPromise } = require('./dataFetcher');

describe('Async Data Fetching', () => {
  // 1. 测试回调函数 (不推荐,容易出错,但 Jest 支持)
  // 需要在测试用例中接收一个 `done` 参数,并在异步操作完成时调用它
  describe('fetchDataWithCallback', () => {
    test('should fetch data using callback (success)', (done) => {
      fetchDataWithCallback((error, data) => {
        try {
          expect(error).toBeNull();
          expect(data).toEqual({ user: 'John Doe', id: 1 });
          done(); // 必须调用 done(),否则测试会超时
        } catch (e) {
          done(e); // 如果断言失败,将错误传递给 done
        }
      });
    });

    // 模拟错误情况,需要修改 fetchDataWithCallback 来触发错误
    // test('should handle error using callback', (done) => {
    //   fetchDataWithCallback((error, data) => {
    //     try {
    //       expect(error).toBeInstanceOf(Error);
    //       expect(error.message).toBe('Network error');
    //       expect(data).toBeUndefined();
    //       done();
    //     } catch (e) {
    //       done(e);
    //     }
    //   });
    // });
  });

  // 2. 测试 Promise
  describe('fetchDataWithPromise', () => {
    // 2.1. 使用 return Promise
    test('should fetch data using promise (resolves)', () => {
      // 必须 return Promise
      return fetchDataWithPromise().then(data => {
        expect(data).toHaveProperty('user');
        expect(data).toHaveProperty('id');
      });
    });

    // 2.2. 使用 .resolves/.rejects 匹配器 (更简洁)
    test('should fetch data using promise with .resolves', () => {
      return expect(fetchDataWithPromise()).resolves.toHaveProperty('user');
    });

    // 为了测试 reject,我们需要确保 fetchDataWithPromise 会 reject
    // 这通常通过 mock 来控制,或者多次运行直到它失败(不推荐用于单元测试)
    // 假设我们有一个确定会 reject 的版本:
    const fetchDataThatRejects = () => Promise.reject(new Error('Simulated rejection'));

    test('should handle promise rejection with .rejects', () => {
      return expect(fetchDataThatRejects()).rejects.toThrow('Simulated rejection');
    });

    test('should handle promise rejection with try/catch in then', () => {
      // 确保断言了 reject 的情况,否则 Promise reject 但没有 catch 会导致测试失败
      expect.assertions(1); // 确保至少有一个断言被调用
      return fetchDataThatRejects().catch(e => {
        expect(e.message).toMatch(/rejection/);
      });
    });

    // 3. 使用 async/await (推荐)
    test('should fetch data using async/await (resolves)', async () => {
      try {
        const data = await fetchDataWithPromise();
        expect(data).toHaveProperty('user');
        expect(data).toHaveProperty('id');
      } catch (e) {
        // 如果 fetchDataWithPromise 意外 reject,测试会失败
        // 这个 catch 块可能不会被执行,除非 fetchDataWithPromise 固定 reject
      }
    });

    test('should handle promise rejection using async/await with try/catch', async () => {
      expect.assertions(1); // 确保 catch 块中的断言被执行
      try {
        await fetchDataThatRejects();
      } catch (e) {
        expect(e.message).toBe('Simulated rejection');
      }
    });

    test('should fetch data using async/await with .resolves', async () => {
      await expect(fetchDataWithPromise()).resolves.toHaveProperty('id');
    });

    test('should handle promise rejection using async/await with .rejects', async () => {
      await expect(fetchDataThatRejects()).rejects.toThrow('Simulated rejection');
    });
  });
});

示例4: Mocking (模拟)

Mocking 是单元测试中非常重要的一部分,它允许我们替换模块、函数或 API 的真实实现,以便:

  • 隔离被测单元:确保测试只关注当前单元的逻辑,不受外部依赖的影响。
  • 控制行为:使外部依赖返回特定的值或行为,以测试不同的代码路径。
  • 避免副作用:防止测试对外部系统(如数据库、网络服务)产生实际影响。
  • 提高测试速度:用快速的模拟替换慢速的外部调用。

4.1. Mocking Functions (jest.fn())

js 复制代码
// utils/callbacks.js
function processItems(items, callback) {
  items.forEach(item => {
    // 假设对每个 item 做一些处理
    const processedItem = item.toUpperCase();
    callback(processedItem);
  });
}

module.exports = { processItems };
js 复制代码
// utils/callbacks.test.js
const { processItems } = require('./callbacks');

describe('processItems with mock callback', () => {
  test('callback should be called for each item', () => {
    const mockCallback = jest.fn(); // 创建一个 mock 函数
    const items = ['a', 'b', 'c'];

    processItems(items, mockCallback);

    // 断言 mockCallback 被调用的次数
    expect(mockCallback.mock.calls.length).toBe(3);
    // expect(mockCallback).toHaveBeenCalledTimes(3); // 更简洁的写法

    // 断言 mockCallback 第一次被调用时的参数
    expect(mockCallback.mock.calls[0][0]).toBe('A');
    // expect(mockCallback).toHaveBeenNthCalledWith(1, 'A'); // 更简洁

    // 断言 mockCallback 第二次被调用时的参数
    expect(mockCallback.mock.calls[1][0]).toBe('B');
    // expect(mockCallback).toHaveBeenNthCalledWith(2, 'B');

    // 断言 mockCallback 最后一次被调用时的参数
    // expect(mockCallback).toHaveBeenLastCalledWith('C');

    // 检查所有调用参数
    expect(mockCallback.mock.calls).toEqual([['A'], ['B'], ['C']]);
  });

  test('mock function can have a mock implementation', () => {
    const mockFn = jest.fn(x => x * 2); // 提供一个模拟实现
    expect(mockFn(5)).toBe(10);
    expect(mockFn).toHaveBeenCalledWith(5);
  });

  test('mock function can mock return values', () => {
    const mockFn = jest.fn();
    mockFn
      .mockReturnValueOnce(10) // 第一次调用返回 10
      .mockReturnValueOnce('hello') // 第二次调用返回 'hello'
      .mockReturnValue(true); // 后续所有调用返回 true

    expect(mockFn()).toBe(10);
    expect(mockFn()).toBe('hello');
    expect(mockFn()).toBe(true);
    expect(mockFn()).toBe(true);

    expect(mockFn).toHaveBeenCalledTimes(4);
  });

  test('mock function can mock resolved values for promises', async () => {
    const mockAsyncFn = jest.fn();
    mockAsyncFn.mockResolvedValue('resolved data'); // 模拟 Promise resolve

    const result = await mockAsyncFn();
    expect(result).toBe('resolved data');
    expect(mockAsyncFn).toHaveBeenCalled();
  });

  test('mock function can mock rejected values for promises', async () => {
    const mockAsyncFn = jest.fn();
    mockAsyncFn.mockRejectedValue(new Error('Async error')); // 模拟 Promise reject

    try {
      await mockAsyncFn();
    } catch (e) {
      expect(e.message).toBe('Async error');
    }
    expect(mockAsyncFn).toHaveBeenCalled();
  });
});

4.2. Mocking Modules (jest.mock())

假设我们有一个使用 axios 发送 HTTP 请求的模块:

js 复制代码
// services/userService.js
const axios = require('axios');

const API_URL = 'https://jsonplaceholder.typicode.com/users';

async function fetchUser(userId) {
  try {
    const response = await axios.get(`${API_URL}/${userId}`);
    return response.data;
  } catch (error) {
    throw new Error(`Failed to fetch user ${userId}`);
  }
}

async function createUser(userData) {
  try {
    const response = await axios.post(API_URL, userData);
    return response.data;
  } catch (error) {
    throw new Error('Failed to create user');
  }
}

module.exports = { fetchUser, createUser };

测试文件 services/userService.test.js:

js 复制代码
// services/userService.test.js
const { fetchUser, createUser } = require('./userService');
const axios = require('axios'); // 我们将要 mock 这个模块

// 告诉 Jest 模拟整个 axios 模块
// jest.mock() 会被提升到模块顶部,所以它会在所有 import/require 之前执行
jest.mock('axios');

describe('UserService', () => {
  // 清理 mock 状态,确保测试独立
  afterEach(() => {
    // jest.clearAllMocks(); // 清除所有 mock 的 .mock 属性 (calls, instances, etc.)
    // jest.resetAllMocks(); // 清除所有 mock,并移除 mock 实现,恢复到 jest.fn()
    jest.restoreAllMocks(); // 恢复所有 mock 到其原始实现 (如果使用 jest.spyOn)
                         // 对于 jest.mock(),resetAllMocks 通常更合适
    axios.get.mockReset(); // 或者单独重置
    axios.post.mockReset();
  });

  describe('fetchUser', () => {
    test('should fetch a user successfully', async () => {
      const mockUserData = { id: 1, name: 'Leanne Graham' };
      // 为 axios.get 提供一个 mock 实现
      axios.get.mockResolvedValue({ data: mockUserData });

      const user = await fetchUser(1);

      expect(user).toEqual(mockUserData);
      // 验证 axios.get 是否以正确的 URL 被调用
      expect(axios.get).toHaveBeenCalledTimes(1);
      expect(axios.get).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');
    });

    test('should throw an error if fetching user fails', async () => {
      // 模拟 axios.get 抛出错误
      axios.get.mockRejectedValue(new Error('Network Error'));

      // 期望 fetchUser 抛出我们定义的错误
      await expect(fetchUser(1)).rejects.toThrow('Failed to fetch user 1');
      expect(axios.get).toHaveBeenCalledTimes(1);
      expect(axios.get).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');
    });
  });

  describe('createUser', () => {
    test('should create a user successfully', async () => {
      const newUser = { name: 'Test User', email: 'test@example.com' };
      const createdUserResponse = { id: 11, ...newUser };
      axios.post.mockResolvedValue({ data: createdUserResponse });

      const result = await createUser(newUser);

      expect(result).toEqual(createdUserResponse);
      expect(axios.post).toHaveBeenCalledTimes(1);
      expect(axios.post).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users', newUser);
    });

    test('should throw an error if creating user fails', async () => {
      const newUser = { name: 'Test User' };
      axios.post.mockRejectedValue(new Error('Server Error'));

      await expect(createUser(newUser)).rejects.toThrow('Failed to create user');
      expect(axios.post).toHaveBeenCalledTimes(1);
      expect(axios.post).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users', newUser);
    });
  });
});

// 手动 Mock (`__mocks__` 文件夹)
// 如果需要更复杂的模块 mock,可以在与被 mock 模块同级的 `__mocks__` 目录下创建一个文件。
// 例如,为 `axios` 创建 `__mocks__/axios.js`:
/*
// __mocks__/axios.js
export default {
  get: jest.fn(() => Promise.resolve({ data: {} })),
  post: jest.fn(() => Promise.resolve({ data: {} })),
  // ...其他需要 mock 的方法
};
*/
// 然后在测试文件中,`jest.mock('axios')` 会自动使用这个手动 mock。

4.3. Spying on module methods (jest.spyOn())

jest.spyOn 类似于 jest.fn,但它监视对象上现有方法的调用,同时允许原始实现继续执行(除非你显式地 mock 了它的实现)。这对于测试一个方法是否调用了同一模块或对象的其他方法很有用,或者当你只想修改方法的行为一小部分时。

js 复制代码
// utils/logger.js
const logger = {
  log: (message) => {
    console.log(`LOG: ${message}`);
    // 假设这里还有其他逻辑,比如写入文件
  },
  error: (message) => {
    console.error(`ERROR: ${message}`);
  },
  getTimestamp: () => new Date().toISOString(),
  logWithTimestamp: (message) => {
    const timestamp = logger.getTimestamp(); // 调用同一对象的另一个方法
    logger.log(`[${timestamp}] ${message}`);
  }
};
module.exports = logger;
js 复制代码
// utils/logger.test.js
const logger = require('./logger');

describe('Logger Module', () => {
  let logSpy, errorSpy, getTimestampSpy;

  beforeEach(() => {
    // 创建 spy,监视 logger.log 和 logger.error
    // 注意:spyOn 会修改原始对象,所以最好在 afterEach 中 restore
    logSpy = jest.spyOn(logger, 'log');
    errorSpy = jest.spyOn(logger, 'error');
    getTimestampSpy = jest.spyOn(logger, 'getTimestamp');
  });

  afterEach(() => {
    // 恢复原始实现,移除 spy
    // logSpy.mockRestore();
    // errorSpy.mockRestore();
    // getTimestampSpy.mockRestore();
    // 或者一次性恢复所有 spy
    jest.restoreAllMocks();
  });

  test('log method should be called with correct message', () => {
    logger.log('Test message');
    expect(logSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledWith('Test message');
  });

  test('error method should be called with correct message', () => {
    logger.error('Test error');
    expect(errorSpy).toHaveBeenCalledTimes(1);
    expect(errorSpy).toHaveBeenCalledWith('Test error');
  });

  test('logWithTimestamp should call getTimestamp and log', () => {
    const fakeTimestamp = '2025-01-01T00:00:00.000Z';
    // 我们可以 mock spy 的实现
    getTimestampSpy.mockReturnValue(fakeTimestamp);

    logger.logWithTimestamp('Timed message');

    expect(getTimestampSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledWith(`[${fakeTimestamp}] Timed message`);
  });

  test('spy can also mock implementation completely', () => {
    logSpy.mockImplementation(() => {
      // console.log('Mocked implementation for log');
      // 不做任何实际的 console.log 输出
    });

    const originalConsoleLog = console.log;
    console.log = jest.fn(); // 进一步 mock console.log 本身,防止测试输出干扰

    logger.log('This will use mocked log');
    expect(logSpy).toHaveBeenCalledWith('This will use mocked log');
    expect(console.log).not.toHaveBeenCalled(); // 确认原始 console.log 未被调用

    console.log = originalConsoleLog; // 恢复 console.log
  });
});

4.4. Mocking Timers (jest.useFakeTimers())

Jest 允许你模拟定时器 (setTimeout, setInterval, clearTimeout, clearInterval),这样依赖定时器的代码就可以在没有实际等待时间的情况下进行测试。

js 复制代码
// utils/scheduler.js
function runAfterDelay(callback, delay) {
  setTimeout(callback, delay);
}

function runRepeatedly(callback, interval) {
  const intervalId = setInterval(callback, interval);
  return () => clearInterval(intervalId); // 返回一个取消函数
}

module.exports = { runAfterDelay, runRepeatedly };
js 复制代码
// utils/scheduler.test.js
const { runAfterDelay, runRepeatedly } = require('./scheduler');

describe('Scheduler with Timers', () => {
  // 告诉 Jest 使用伪造的定时器
  beforeEach(() => {
    jest.useFakeTimers();
  });

  // 清理伪造的定时器,恢复真实定时器
  afterEach(() => {
    jest.clearAllTimers(); // 清除所有挂起的定时器
    jest.useRealTimers(); // 必须调用,否则会影响其他测试套件
  });

  describe('runAfterDelay', () => {
    test('should execute callback after specified delay', () => {
      const mockCallback = jest.fn();
      const delay = 1000; // 1秒

      runAfterDelay(mockCallback, delay);

      // 此时,回调尚未执行
      expect(mockCallback).not.toHaveBeenCalled();

      // 快进时间,使所有定时器执行
      // jest.runAllTimers();

      // 或者只快进到下一个定时器
      // jest.runOnlyPendingTimers();

      // 或者快进指定的时间
      jest.advanceTimersByTime(delay - 1); // 快进 999ms
      expect(mockCallback).not.toHaveBeenCalled();

      jest.advanceTimersByTime(1); // 再快进 1ms
      expect(mockCallback).toHaveBeenCalledTimes(1);
    });
  });

  describe('runRepeatedly', () => {
    test('should execute callback repeatedly at specified interval', () => {
      const mockCallback = jest.fn();
      const interval = 500; // 0.5秒

      const cancel = runRepeatedly(mockCallback, interval);

      expect(mockCallback).not.toHaveBeenCalled();

      jest.advanceTimersByTime(interval);
      expect(mockCallback).toHaveBeenCalledTimes(1);

      jest.advanceTimersByTime(interval);
      expect(mockCallback).toHaveBeenCalledTimes(2);

      jest.advanceTimersByTime(interval * 3); // 快进 3 个间隔
      expect(mockCallback).toHaveBeenCalledTimes(5); // 之前2次 + 新的3次

      cancel(); // 取消 interval

      jest.advanceTimersByTime(interval * 2); // 即使时间再流逝
      expect(mockCallback).toHaveBeenCalledTimes(5); // 回调也不再执行
    });
  });
});

示例5: Snapshot Testing

快照测试非常适合用于确保 UI 组件的结构或大型对象的序列化形式不会意外改变。

js 复制代码
// utils/formatter.js
function createComplexObject(name, version, features) {
  return {
    packageName: name,
    versionDetails: {
      major: parseInt(version.split('.')[0], 10),
      minor: parseInt(version.split('.')[1], 10),
      patch: parseInt(version.split('.')[2], 10),
      full: version,
    },
    featureList: features.sort(),
    createdAt: new Date('2025-01-15T10:00:00.000Z'), // 固定日期以保证快照一致性
    metadata: {
      stable: version.includes('beta') === false,
      tags: ['utility', 'formatter']
    }
  };
}

module.exports = { createComplexObject };
js 复制代码
// utils/formatter.test.js
const { createComplexObject } = require('./formatter');

describe('createComplexObject', () => {
  test('should generate a consistent object structure', () => {
    const obj1 = createComplexObject('my-lib', '1.2.3', ['featureA', 'featureC', 'featureB']);
    // 第一次运行时,会在同级目录下生成一个 __snapshots__/formatter.test.js.snap 文件
    // 该文件包含了 obj1 的序列化快照
    expect(obj1).toMatchSnapshot();
  });

  test('should handle different versions and features', () => {
    const obj2 = createComplexObject('another-lib', '2.0.0-beta.1', ['core', 'extended']);
    // 这会生成第二个快照,并追加到 .snap 文件中
    expect(obj2).toMatchSnapshot();
  });

  // 如果你希望快照直接写在测试文件中,可以使用 toMatchInlineSnapshot
  test('inline snapshot example', () => {
    const simpleData = { name: 'Test', value: 42 };
    // 第一次运行时,Prettier (如果配置了) 会自动将快照内容填充到函数参数中
    expect(simpleData).toMatchInlineSnapshot(`
{
  "name": "Test",
  "value": 42,
}
`);
  });

  test('property matchers with snapshots', () => {
    const dynamicData = {
      id: Math.floor(Math.random() * 1000), // 动态变化的值
      name: 'Dynamic Object',
      timestamp: new Date(), // 动态变化的值
      fixedValue: 'always the same'
    };

    // 对于动态变化的值,我们可以使用 expect.any() 或其他属性匹配器
    expect(dynamicData).toMatchSnapshot({
      id: expect.any(Number), // id 只要是数字就行
      timestamp: expect.any(Date), // timestamp 只要是 Date 对象就行
      // createdAt: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), // 更精确的日期格式匹配
    });
    // 快照中,id 和 timestamp 的值会被替换为 "Number" 和 "Date"
    // 在 .snap 文件中会是类似:
    // id: Any<Number>,
    // timestamp: Any<Date>,
  });
});

当运行 npm test 后,如果 formatter.test.js.snap 文件不存在或内容不匹配,测试会失败。

  • 如果快照是新的或变更符合预期,运行 npm test -- -u (或 jest -u) 来更新快照。
  • 快照文件应该和代码一起提交到版本控制系统。

示例6: (简述) React 组件测试 (使用 React Testing Library)

这部分代码会更长,并且需要额外的依赖。这里只给出一个概念性的示例。

安装依赖:

bash 复制代码
npm install --save-dev react @testing-library/react @testing-library/jest-dom jest-environment-jsdom @babel/preset-react @babel/preset-env

Babel 配置 (babel.config.js):

js 复制代码
// babel.config.js
module.exports = {
  presets: [
    '@babel/preset-env',
    ['@babel/preset-react', { runtime: 'automatic' }] // 'automatic' 适用于 React 17+
  ],
};

Jest 配置 (jest.config.jspackage.json):

js 复制代码
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom', // 模拟浏览器环境
  setupFilesAfterEnv: ['<rootDir>/jest-setup.js'], // 测试环境搭建后执行的脚本
  moduleNameMapper: { // 如果你使用 CSS Modules
    '\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  // transform: { // 如果不用 babel.config.js,可以在这里配置
  //   '^.+\.(js|jsx)$': 'babel-jest',
  // },
};

Jest Setup 文件 (jest-setup.js):

js 复制代码
// jest-setup.js
// 扩展 Jest 的 expect,使其支持 @testing-library/jest-dom 提供的 DOM 相关匹配器
import '@testing-library/jest-dom';

一个简单的 React 组件 (components/Greeting.js):

jsx 复制代码
// components/Greeting.js
import React, { useState } from 'react';

function Greeting({ name }) {
  const [message, setMessage] = useState(`Hello, ${name}!`);

  const handleChangeMessage = () => {
    setMessage(`Goodbye, ${name}!`);
  };

  if (!name) {
    return <div>Please provide a name.</div>;
  }

  return (
    <div>
      <h1>{message}</h1>
      <button onClick={handleChangeMessage}>Change Message</button>
    </div>
  );
}

export default Greeting;

测试文件 (components/Greeting.test.js):

js 复制代码
// components/Greeting.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Greeting from './Greeting';

describe('Greeting Component', () => {
  test('renders greeting message with provided name', () => {
    render(<Greeting name="World" />);
    // screen.getByText 会查找包含指定文本的元素
    // 使用正则表达式忽略大小写,并允许部分匹配
    const headingElement = screen.getByRole('heading', { name: /hello, world!/i });
    expect(headingElement).toBeInTheDocument(); // 来自 @testing-library/jest-dom
  });

  test('renders fallback message if name is not provided', () => {
    render(<Greeting />);
    expect(screen.getByText(/please provide a name/i)).toBeInTheDocument();
  });

  test('changes message when button is clicked', () => {
    render(<Greeting name="Alice" />);

    // 初始消息
    expect(screen.getByRole('heading', { name: 'Hello, Alice!' })).toBeInTheDocument();

    // 找到按钮并点击
    const buttonElement = screen.getByRole('button', { name: /change message/i });
    fireEvent.click(buttonElement);

    // 检查消息是否已更改
    expect(screen.getByRole('heading', { name: 'Goodbye, Alice!' })).toBeInTheDocument();
    // 确认旧消息已不存在 (或者使用 queryByText)
    expect(screen.queryByRole('heading', { name: 'Hello, Alice!' })).not.toBeInTheDocument();
  });

  test('matches snapshot', () => {
    const { container } = render(<Greeting name="Snapshot" />);
    // container 是组件渲染的根 DOM 节点
    expect(container.firstChild).toMatchSnapshot();
  });
});

这只是 Jest 功能的冰山一角,但涵盖了最常用和最重要的部分。编写好的单元测试需要实践和对被测代码的理解。关键在于编写清晰、独立、可维护的测试,它们能够准确地反映你的代码行为,并在代码发生回归时提供快速反馈。

相关推荐
崔庆才丨静觅44 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax