单元测试
单元测试是软件测试的基础,它验证代码的最小可测试单元是否按预期工作。本章节将深入介绍单元测试的概念、方法和最佳实践。
什么是单元测试?
单元测试是对软件中的最小可测试单元进行验证的测试方法。在面向对象编程中,一个单元通常是一个方法或类;在过程式编程中,一个单元通常是一个函数。
单元测试的特点
┌─────────────────────────────────────────────────────────────┐
│ 单元测试特征 │
├─────────────────────────────────────────────────────────────┤
│ ⚡ 执行速度快 │ 通常在毫秒级别完成 │
│ 🔒 隔离性强 │ 不依赖外部系统(数据库、网络等) │
│ 🎯 定位精准 │ 快速定位问题所在 │
│ 🔄 可重复执行 │ 多次执行结果一致 │
│ 📝 文档作用 │ 展示代码的预期行为 │
│ 🛡️ 回归保护 │ 防止修改引入新问题 │
└─────────────────────────────────────────────────────────────┘
为什么需要单元测试?
-
提高代码质量
- 及早发现和修复缺陷
- 强制思考边界条件和异常情况
-
支持重构
- 有测试保护,重构时更有信心
- 确保重构后功能仍然正确
-
改善设计
- 可测试的代码通常设计更好
- 促进单一职责原则
-
提供文档
- 测试用例展示了代码的使用方式
- 比注释更可靠的文档
单元测试结构
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(应该做)
-
保持测试简单
- 每个测试只验证一个概念
- 避免复杂的逻辑
-
使用描述性名称
- 测试名称应该说明测试的内容
- 当测试失败时,名称应该帮助定位问题
-
保持测试独立
- 测试之间不应该相互依赖
- 可以单独运行任何一个测试
-
测试边界条件
- 空值、零值、最大值、最小值
- 异常和错误情况
-
使用 Given-When-Then 或 AAA 模式
- 使测试结构清晰
- 便于理解和维护
DON'T(不应该做)
-
不要测试私有方法
- 通过公共接口测试私有方法的行为
- 私有方法的测试是脆弱的
-
不要依赖外部资源
- 使用 Mock/Stub 替代数据库、网络等
- 保持测试快速和稳定
-
不要忽略失败的测试
- 失败的测试应该立即修复
- 注释掉的测试应该被删除或修复
-
不要写过于复杂的测试
- 如果测试需要很多设置,可能是设计问题
- 考虑重构被测代码
常见问题
1. 测试太多还是太少?
- 关注业务逻辑和复杂逻辑
- 简单的 getter/setter 通常不需要测试
- 优先测试容易出错和关键的代码
2. 如何处理遗留代码?
- 在修改前添加测试( characterization tests )
- 逐步增加覆盖率
- 重构和测试并行进行
3. 测试数据如何管理?
- 使用 Factory/Builder 模式创建测试数据
- 使用 Fixture 共享通用设置
- 避免在测试中硬编码大量数据
练习
- 为一个简单的计算器类编写完整的单元测试
- 使用 Mock 测试一个依赖外部 API 的服务
- 为边界条件编写测试用例
- 实现参数化测试
- 配置测试覆盖率报告