跳到主要内容

单元测试

单元测试是软件测试的基础,它验证代码的最小可测试单元是否按预期工作。本章节将深入介绍单元测试的概念、方法和最佳实践。

什么是单元测试?

单元测试是对软件中的最小可测试单元进行验证的测试方法。在面向对象编程中,一个单元通常是一个方法或类;在过程式编程中,一个单元通常是一个函数。

单元测试的特点

┌─────────────────────────────────────────────────────────────┐
│ 单元测试特征 │
├─────────────────────────────────────────────────────────────┤
│ ⚡ 执行速度快 │ 通常在毫秒级别完成 │
│ 🔒 隔离性强 │ 不依赖外部系统(数据库、网络等) │
│ 🎯 定位精准 │ 快速定位问题所在 │
│ 🔄 可重复执行 │ 多次执行结果一致 │
│ 📝 文档作用 │ 展示代码的预期行为 │
│ 🛡️ 回归保护 │ 防止修改引入新问题 │
└─────────────────────────────────────────────────────────────┘

为什么需要单元测试?

  1. 提高代码质量

    • 及早发现和修复缺陷
    • 强制思考边界条件和异常情况
  2. 支持重构

    • 有测试保护,重构时更有信心
    • 确保重构后功能仍然正确
  3. 改善设计

    • 可测试的代码通常设计更好
    • 促进单一职责原则
  4. 提供文档

    • 测试用例展示了代码的使用方式
    • 比注释更可靠的文档

单元测试结构

AAA 模式

AAA(Arrange-Act-Assert)是编写单元测试的常用模式:

def test_calculator_add():
# Arrange(准备)
calculator = Calculator()
a, b = 2, 3

# Act(执行)
result = calculator.add(a, b)

# Assert(断言)
assert result == 5

Given-When-Then 模式

BDD(行为驱动开发)风格的测试结构:

def test_user_login():
# Given(给定)
user = User(username="test", password="123456")

# When(当)
is_logged_in = user.login("test", "123456")

# Then(那么)
assert is_logged_in is True

编写好的单元测试

测试命名规范

好的测试名称应该清晰描述测试的行为:

# ❌ 不好的命名
def test_add():
pass

def test_1():
pass

# ✅ 好的命名
def test_add_should_return_sum_of_two_positive_numbers():
pass

def test_add_should_return_correct_result_when_adding_negative_numbers():
pass

def test_divide_should_raise_exception_when_dividing_by_zero():
pass

一个测试一个概念

每个测试只验证一个概念或行为:

# ❌ 不好的做法:一个测试验证多个概念
def test_user():
user = User("张三", 25)
assert user.name == "张三"
assert user.age == 25
assert user.is_active is True
user.deactivate()
assert user.is_active is False

# ✅ 好的做法:每个测试一个概念
def test_user_should_have_correct_name_after_creation():
user = User("张三", 25)
assert user.name == "张三"

def test_user_should_have_correct_age_after_creation():
user = User("张三", 25)
assert user.age == 25

def test_user_should_be_active_by_default():
user = User("张三", 25)
assert user.is_active is True

def test_user_should_be_inactive_after_deactivation():
user = User("张三", 25)
user.deactivate()
assert user.is_active is False

测试边界条件

边界条件是缺陷的高发区:

import pytest

def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b

class TestDivide:
"""测试除法函数"""

def test_normal_division(self):
"""测试正常除法"""
assert divide(10, 2) == 5

def test_divide_by_zero(self):
"""测试除以零"""
with pytest.raises(ValueError, match="除数不能为零"):
divide(10, 0)

def test_zero_dividend(self):
"""测试被除数为零"""
assert divide(0, 5) == 0

def test_negative_numbers(self):
"""测试负数"""
assert divide(-10, 2) == -5
assert divide(10, -2) == -5
assert divide(-10, -2) == 5

def test_float_result(self):
"""测试浮点结果"""
result = divide(7, 3)
assert abs(result - 2.333) < 0.001

测试替身(Test Doubles)

当测试的单元依赖其他组件时,使用测试替身来隔离被测单元。

测试替身类型

┌─────────────────────────────────────────────────────────────┐
│ 测试替身类型 │
├─────────────────────────────────────────────────────────────┤
│ Dummy │ 占位对象,不实际使用 │
│ Fake │ 简化实现,如内存数据库 │
│ Stub │ 预设返回值,不响应外部调用 │
│ Spy │ 记录调用信息,用于验证 │
│ Mock │ 预设期望,验证交互 │
└─────────────────────────────────────────────────────────────┘

Stub 示例

from unittest.mock import Mock

class TestOrderService:
"""测试订单服务"""

def test_calculate_total_with_stub(self):
# 创建 Stub
price_service = Mock()
price_service.get_price.return_value = 100

order_service = OrderService(price_service)
total = order_service.calculate_total(["item1", "item2"])

assert total == 200

Mock 示例

from unittest.mock import Mock, patch

def test_send_notification():
"""测试发送通知"""
# 创建 Mock
email_service = Mock()

notifier = Notifier(email_service)
notifier.notify_user("[email protected]", "Hello")

# 验证交互
email_service.send.assert_called_once_with(
to="[email protected]",
subject="Notification",
body="Hello"
)

使用 patch

from unittest.mock import patch, MagicMock

# 方式1:装饰器
@patch('module.requests.get')
def test_fetch_data(mock_get):
mock_get.return_value.json.return_value = {"data": "test"}

result = fetch_data()

assert result == {"data": "test"}
mock_get.assert_called_once()

# 方式2:上下文管理器
def test_fetch_data_with_context():
with patch('module.requests.get') as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"data": "test"}
mock_get.return_value = mock_response

result = fetch_data()

assert result == {"data": "test"}

参数化测试

使用不同参数运行相同的测试逻辑:

import pytest

# Python pytest 示例
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("World", "WORLD"),
("", ""),
("123", "123"),
])
def test_to_uppercase(input, expected):
assert input.upper() == expected

# 多参数示例
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected
// Java JUnit 5 示例
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30"
})
void testAdd(int a, int b, int expected) {
Calculator calculator = new Calculator();
assertEquals(expected, calculator.add(a, b));
}
// JavaScript Jest 示例
describe('add', () => {
test.each([
[1, 1, 2],
[2, 3, 5],
[10, 20, 30],
])('add(%i, %i) returns %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
});

测试覆盖率

覆盖率类型

┌─────────────────────────────────────────────────────────────┐
│ 代码覆盖率类型 │
├─────────────────────────────────────────────────────────────┤
│ 语句覆盖 │ 每行代码是否被执行 │
│ 分支覆盖 │ 每个条件分支是否被执行 │
│ 函数覆盖 │ 每个函数是否被调用 │
│ 行覆盖 │ 每行源代码是否被执行 │
│ 条件覆盖 │ 每个布尔子表达式是否都被评估 │
└─────────────────────────────────────────────────────────────┘

覆盖率工具

# Python - pytest-cov
pytest --cov=myapp --cov-report=html

# JavaScript - Jest
jest --coverage

# Java - JaCoCo
./mvnw jacoco:report

# Go
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

覆盖率目标

  • 语句覆盖率:通常目标 80%+
  • 分支覆盖率:通常目标 70%+
  • 关键业务逻辑:应该达到 100%

注意:高覆盖率不等于高质量测试,重要的是测试的有效性。

测试组织

目录结构

project/
├── src/
│ ├── calculator.py
│ └── user_service.py
└── tests/
├── __init__.py
├── test_calculator.py
├── test_user_service.py
└── conftest.py # pytest 配置文件

测试类组织

# test_calculator.py
import pytest

class TestCalculator:
"""计算器测试类"""

@pytest.fixture
def calculator(self):
"""创建计算器实例"""
return Calculator()

class TestAddition:
"""加法测试组"""

def test_add_positive_numbers(self, calculator):
assert calculator.add(2, 3) == 5

def test_add_negative_numbers(self, calculator):
assert calculator.add(-2, -3) == -5

class TestDivision:
"""除法测试组"""

def test_divide_normal(self, calculator):
assert calculator.divide(10, 2) == 5

def test_divide_by_zero(self, calculator):
with pytest.raises(ValueError):
calculator.divide(10, 0)

各语言单元测试示例

Python

# calculator.py
class Calculator:
def add(self, a: int, b: int) -> int:
return a + b

def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
@pytest.fixture
def calc(self):
return Calculator()

def test_add(self, calc):
assert calc.add(2, 3) == 5

def test_divide_by_zero(self, calc):
with pytest.raises(ValueError):
calc.divide(10, 0)

Java

// Calculator.java
public class Calculator {
public int add(int a, int b) {
return a + b;
}

public double divide(double a, double b) {
if (b == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return a / b;
}
}

// CalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
private Calculator calculator;

@BeforeEach
void setUp() {
calculator = new Calculator();
}

@Test
void testAdd() {
assertEquals(5, calculator.add(2, 3));
}

@Test
void testDivideByZero() {
assertThrows(IllegalArgumentException.class, () -> {
calculator.divide(10, 0);
});
}
}

JavaScript

// calculator.js
class Calculator {
add(a, b) {
return a + b;
}

divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
}

module.exports = Calculator;

// calculator.test.js
const Calculator = require('./calculator');

describe('Calculator', () => {
let calc;

beforeEach(() => {
calc = new Calculator();
});

test('add should return sum', () => {
expect(calc.add(2, 3)).toBe(5);
});

test('divide by zero should throw', () => {
expect(() => calc.divide(10, 0)).toThrow('Cannot divide by zero');
});
});

Go

// calculator.go
package calculator

import "errors"

func Add(a, b int) int {
return a + b
}

func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}

func TestDivide(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("Expected error for division by zero")
}
}

// 表驱动测试
func TestAddTableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
{"zero", 0, 5, 5},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}

单元测试最佳实践

DO(应该做)

  1. 保持测试简单

    • 每个测试只验证一个概念
    • 避免复杂的逻辑
  2. 使用描述性名称

    • 测试名称应该说明测试的内容
    • 当测试失败时,名称应该帮助定位问题
  3. 保持测试独立

    • 测试之间不应该相互依赖
    • 可以单独运行任何一个测试
  4. 测试边界条件

    • 空值、零值、最大值、最小值
    • 异常和错误情况
  5. 使用 Given-When-Then 或 AAA 模式

    • 使测试结构清晰
    • 便于理解和维护

DON'T(不应该做)

  1. 不要测试私有方法

    • 通过公共接口测试私有方法的行为
    • 私有方法的测试是脆弱的
  2. 不要依赖外部资源

    • 使用 Mock/Stub 替代数据库、网络等
    • 保持测试快速和稳定
  3. 不要忽略失败的测试

    • 失败的测试应该立即修复
    • 注释掉的测试应该被删除或修复
  4. 不要写过于复杂的测试

    • 如果测试需要很多设置,可能是设计问题
    • 考虑重构被测代码

常见问题

1. 测试太多还是太少?

  • 关注业务逻辑和复杂逻辑
  • 简单的 getter/setter 通常不需要测试
  • 优先测试容易出错和关键的代码

2. 如何处理遗留代码?

  • 在修改前添加测试( characterization tests )
  • 逐步增加覆盖率
  • 重构和测试并行进行

3. 测试数据如何管理?

  • 使用 Factory/Builder 模式创建测试数据
  • 使用 Fixture 共享通用设置
  • 避免在测试中硬编码大量数据

练习

  1. 为一个简单的计算器类编写完整的单元测试
  2. 使用 Mock 测试一个依赖外部 API 的服务
  3. 为边界条件编写测试用例
  4. 实现参数化测试
  5. 配置测试覆盖率报告

参考资源