单元测试最佳实践:构建可靠软件的基石

2026-02-13 09:00:00 · 2 minute read

单元测试是软件开发中不可或缺的一部分,它帮助开发者确保代码的正确性、提高软件质量,并为后续的代码重构提供安全保障。然而,编写有效的单元测试并非易事。本文将分享一些单元测试的最佳实践,帮助你构建更可靠的测试体系。

什么是单元测试

单元测试是对软件中最小可测试单元(通常是单个函数、方法或类)进行验证的测试。它的目标是隔离代码的一部分,并验证这部分代码在各种条件下是否按预期工作。

单元测试的核心原则

1. 独立性

每个单元测试应该独立运行,不依赖于其他测试的结果。测试之间不应该有共享状态,测试的执行顺序不应该影响结果。这样可以保证测试的可预测性和可维护性。

// 不好的做法:测试之间有依赖
let counter = 0;

test('第一个测试', () => {
  counter++;
  expect(counter).toBe(1);
});

test('第二个测试', () => {
  counter++;  // 依赖前一个测试的状态
  expect(counter).toBe(2);
});

// 好的做法:每个测试独立
test('第一个测试', () => {
  const result = add(1, 1);
  expect(result).toBe(2);
});

test('第二个测试', () => {
  const result = multiply(2, 3);
  expect(result).toBe(6);
});

2. 可重复性

单元测试应该能够无限次重复运行,并且每次都产生相同的结果。如果测试依赖于外部系统(如数据库、网络 API),这些依赖应该被模拟或隔离。

3. 快速性

单元测试应该快速执行。如果测试运行太慢,开发者就不会频繁运行它们,从而失去测试的价值。通常,一个单元测试套件应该在几秒钟内完成。

4. 可读性

测试代码应该像生产代码一样清晰易读。测试的名称应该清楚地描述它正在测试什么,失败的测试应该能够快速指出问题所在。

测试命名规范

好的测试名称能够清晰表达测试的目的和行为。推荐使用"should"或"when…then…“的模式:

// 好的命名
test('应该返回两个数字的和', () => {
  expect(add(2, 3)).toBe(5);
});

test('当输入为空数组时,应该返回 0', () => {
  expect(sum([])).toBe(0);
});

test('当用户名已存在时,应该抛出错误', () => {
  expect(() => createUser('existing-user')).toThrow('用户名已存在');
});

AAA 模式

Arrange-Act-Assert(准备-执行-断言)模式是编写清晰单元测试的经典方法:

test('计算折扣后价格', () => {
  // Arrange(准备)
  const originalPrice = 100;
  const discountRate = 0.2;
  const calculator = new PriceCalculator();

  // Act(执行)
  const discountedPrice = calculator.applyDiscount(originalPrice, discountRate);

  // Assert(断言)
  expect(discountedPrice).toBe(80);
});

测试覆盖率 vs 测试质量

高测试覆盖率并不等于好的测试质量。100% 的代码覆盖率可能只测试了代码的基本路径,而没有考虑边界情况和错误处理。

重要的是测试质量而非数量。一个测试关键业务逻辑和边缘情况的测试套件,比覆盖所有代码但只测试简单路径的测试套套更有价值。

避免测试实现细节

单元测试应该测试行为,而不是实现细节。测试实现细节会使代码重构变得困难,因为即使行为没有改变,实现细节的改变也可能导致测试失败。

// 不好的做法:测试内部实现
test('应该调用内部方法 calculateTax', () => {
  const calculator = new PriceCalculator();
  const spy = jest.spyOn(calculator, 'calculateTax');
  calculator.calculateTotal(100);
  expect(spy).toHaveBeenCalled();
});

// 好的做法:测试最终行为
test('应该返回包含税的总价', () => {
  const calculator = new PriceCalculator();
  const total = calculator.calculateTotal(100);
  expect(total).toBe(110);  // 假设税率是 10%
});

使用 Mock 和 Stub

当被测试的代码依赖外部系统或复杂对象时,可以使用 mock 和 stub 来隔离这些依赖:

test('应该从 API 获取用户数据', async () => {
  // 使用 mock API
  const mockApi = {
    fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Test User' })
  };

  const userService = new UserService(mockApi);
  const user = await userService.getUser(1);

  expect(mockApi.fetchUser).toHaveBeenCalledWith(1);
  expect(user).toEqual({ id: 1, name: 'Test User' });
});

测试边界条件和错误处理

好的单元测试不仅测试正常情况,还测试边界条件和错误处理:

test('当除数为零时,应该抛出错误', () => {
  expect(() => divide(10, 0)).toThrow('除数不能为零');
});

test('当输入为负数时,应该返回 0', () => {
  expect(calculateAbs(-5)).toBe(5);
});

test('当数组为空时,应该返回空数组', () => {
  expect(filterEmpty([])).toEqual([]);
});

保持测试简洁

测试代码应该简洁明了,避免复杂的逻辑。如果测试代码过于复杂,可能意味着被测试的代码需要重构,或者测试本身需要改进。

// 不好的做法:测试代码过于复杂
test('复杂的测试逻辑', () => {
  const result = someFunction();
  let expected;
  if (result.type === 'A') {
    expected = calculateExpectedForTypeA(result);
  } else if (result.type === 'B') {
    expected = calculateExpectedForTypeB(result);
  } else {
    expected = calculateDefault(result);
  }
  expect(result.value).toBe(expected);
});

// 好的做法:拆分为多个简单测试
test('当类型为 A 时,应该返回正确结果', () => {
  const result = someFunction('A');
  expect(result.value).toBe(expectedForTypeA);
});

test('当类型为 B 时,应该返回正确结果', () => {
  const result = someFunction('B');
  expect(result.value).toBe(expectedForTypeB);
});

集成到开发流程

单元测试应该成为开发流程的一部分,而不是事后的补充:

  1. TDD(测试驱动开发):先写测试,再写代码
  2. 持续集成:每次提交代码时自动运行测试
  3. 代码审查:审查代码时同时审查测试
  4. 维护测试:随着代码的演进,及时更新测试

总结

单元测试是构建可靠软件的重要工具。遵循这些最佳实践,可以帮助你编写更有效、更易维护的测试:

记住,好的单元测试不仅能够发现 bug,还能作为代码的活文档,帮助团队成员理解代码的预期行为。投入时间编写高质量的单元测试,将会在项目的整个生命周期中带来巨大的回报。

已复制