单元测试
单元测试是软件测试的基础,它验证代码的最小可测试单元是否按预期工作。本章节将深入介绍单元测试的概念、方法和最佳实践。
什么是单元测试?
单元测试是对软件中的最小可测试单元进行验证的测试方法。在面向对象编程中,一个单元通常是一个方法或类;在过程式编程中,一个单元通常是一个函数。
单元测试的特征
为什么需要单元测试?
单元测试带来的价值远远超过编写测试的时间成本:
-
提高代码质量
- 及早发现和修复缺陷,降低修复成本
- 强制思考边界条件和异常情况,完善代码逻辑
- 代码可测试性促进更好的设计
-
支持重构
- 有测试保护,重构时更有信心
- 确保重构后功能仍然正确
- 快速验证修改是否引入问题
-
改善设计
- 可测试的代码通常设计更好
- 促进单一职责原则
- 降低代码耦合度
-
提供文档
- 测试用例展示了代码的使用方式
- 比注释更可靠的文档(注释可能过时)
- 新成员可以通过测试了解代码行为
单元测试结构
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
各阶段详解:
| 阶段 | 英文 | 职责 | 示例 |
|---|---|---|---|
| 准备 | Arrange | 创建对象、设置数据、准备环境 | calculator = Calculator() |
| 执行 | Act | 调用被测方法 | result = calculator.add(2, 3) |
| 断言 | Assert | 验证结果是否符合预期 | assert result == 5 |
Given-When-Then 模式
BDD(行为驱动开发)风格的测试结构,更贴近业务语言,便于与非技术人员沟通:
def test_user_login():
"""
Given: 用户已注册且提供正确凭据
When: 用户尝试登录
Then: 登录成功
"""
# Given(给定)- 初始状态
user = User(username="test", password="123456")
user.save()
# When(当)- 执行操作
is_logged_in = user.login("test", "123456")
# Then(那么)- 验证结果
assert is_logged_in is True
两种模式对比:
| 特点 | AAA 模式 | Given-When-Then 模式 |
|---|---|---|
| 来源 | xUnit | BDD(行为驱动开发) |
| 适用场景 | 技术团队 | 技术团队 + 业务人员 |
| 表达方式 | 技术术语 | 业务语言 |
| 可读性 | 好 | 更好 |
编写好的单元测试
测试命名规范
好的测试名称应该清晰描述测试的行为,当测试失败时,名称本身就能帮助定位问题:
# ❌ 不好的命名 - 无法理解测试目的
def test_add():
pass
def test_1():
pass
def test_user():
pass
# ✅ 好的命名 - 清晰描述测试场景和预期结果
def test_add_should_return_sum_of_two_positive_numbers():
"""验证两个正数相加返回正确结果"""
pass
def test_add_should_return_negative_result_when_adding_negative_numbers():
"""验证负数相加返回负数结果"""
pass
def test_divide_should_raise_exception_when_dividing_by_zero():
"""验证除以零时抛出异常"""
pass
命名模板推荐:test_<方法名>_<场景>_<预期结果>
一个测试一个概念
每个测试只验证一个概念或行为,这样当测试失败时能快速定位问题:
# ❌ 不好的做法:一个测试验证多个概念
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.333333) < 0.001 # 浮点数近似比较
def test_very_small_numbers(self):
"""测试极小数值 - 边界条件"""
assert divide(0.0001, 0.0001) == pytest.approx(1.0)
常见边界值:
| 数据类型 | 边界值示例 |
|---|---|
| 整数 | 0, 1, -1, 最大值, 最小值 |
| 字符串 | 空字符串, 单字符, 最大长度 |
| 集合 | 空集合, 单元素, 边界索引 |
| 日期 | 今天, 昨天, 明天, 月初, 月末 |
测试替身(Test Doubles)
当测试的单元依赖其他组件时,使用测试替身来隔离被测单元。这是单元测试的核心技术之一。
测试替身类型
Dummy(哑对象)
Dummy 是最简单的测试替身,它只是用来填充参数列表,从不被实际使用。
class DummyLogger:
"""Dummy Logger - 只是为了满足参数要求"""
def info(self, msg):
pass # 什么都不做
def error(self, msg):
pass
def test_create_user_with_dummy():
# DummyLogger 只是一个占位符,不会被实际调用
user_service = UserService(DummyLogger())
user = user_service.create("张三")
assert user.name == "张三"
Fake(伪造对象)
Fake 有真实的实现,但采取了某些捷径,不适合生产环境使用。最常见的例子是内存数据库。
class FakeDatabase:
"""Fake 数据库 - 使用内存存储代替真实数据库"""
def __init__(self):
self._data = {}
self._next_id = 1
def save(self, entity):
"""保存实体"""
if entity.id is None:
entity.id = self._next_id
self._next_id += 1
self._data[entity.id] = entity
return entity
def find_by_id(self, id):
"""根据ID查找"""
return self._data.get(id)
def delete(self, id):
"""删除实体"""
if id in self._data:
del self._data[id]
return True
return False
# 测试中使用 Fake
def test_user_repository_with_fake():
fake_db = FakeDatabase()
repo = UserRepository(fake_db)
user = User(name="张三", email="[email protected]")
saved = repo.save(user)
assert saved.id is not None
assert repo.find_by_id(saved.id).name == "张三"
Stub(桩)
Stub 为测试中的调用提供预设的返回值,不会对测试外的调用做出响应。
from unittest.mock import Mock
class PaymentServiceStub:
"""支付服务 Stub - 总是返回成功"""
def charge(self, amount, currency="USD"):
# 总是返回预设的成功响应
return {
"status": "success",
"transaction_id": "txn_stub_123",
"amount": amount,
"currency": currency
}
def test_create_order_with_stub():
payment_stub = PaymentServiceStub()
order_service = OrderService(payment_stub)
order = order_service.create_order(
items=[{"product_id": 1, "price": 100}],
user_id=1
)
assert order.status == "paid"
assert order.transaction_id == "txn_stub_123"
Spy(间谍)
Spy 是一种特殊的 Stub,它会记录调用信息,供测试后续验证。
class EmailServiceSpy:
"""邮件服务 Spy - 记录所有调用"""
def __init__(self):
self.sent_emails = []
self.call_count = 0
def send(self, to, subject, body):
self.call_count += 1
self.sent_emails.append({
"to": to,
"subject": subject,
"body": body
})
return True
def was_called_with(self, to, subject):
"""检查是否被特定参数调用"""
for email in self.sent_emails:
if email["to"] == to and email["subject"] == subject:
return True
return False
def test_notification_service_with_spy():
email_spy = EmailServiceSpy()
notification = NotificationService(email_spy)
notification.notify_user("[email protected]", "欢迎", "欢迎注册!")
# 验证调用
assert email_spy.call_count == 1
assert email_spy.was_called_with("[email protected]", "欢迎")
assert email_spy.sent_emails[0]["body"] == "欢迎注册!"
Mock(模拟对象)
Mock 是最复杂的测试替身。它预先设置期望,验证交互行为是否符合预期。与 Stub 不同,Mock 关注的是「行为验证」而非「状态验证」。
from unittest.mock import Mock, call
def test_order_service_with_mock():
# 创建 Mock
payment_mock = Mock()
email_mock = Mock()
# 设置期望行为
payment_mock.charge.return_value = {
"status": "success",
"transaction_id": "txn_123"
}
# 创建被测对象
order_service = OrderService(payment_mock, email_mock)
# 执行操作
order_service.create_order(
user_id=1,
items=[{"product_id": 1, "price": 100}],
email="[email protected]"
)
# 验证交互 - 这是 Mock 的核心
payment_mock.charge.assert_called_once_with(
amount=100,
currency="USD"
)
email_mock.send.assert_called_once_with(
to="[email protected]",
subject="订单确认",
body=Mock.ANY # 不关心具体内容
)
使用 patch
Python 的 unittest.mock.patch 可以临时替换模块中的对象:
from unittest.mock import patch, MagicMock
# 方式1:装饰器 - 推荐用于固定替换
@patch('module.requests.get')
def test_fetch_data_with_decorator(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"}
# 方式3:补丁对象属性
@patch.object(Calculator, 'api_url', 'http://test.api')
def test_with_patch_object():
calculator = Calculator()
# calculator.api_url 现在是 'http://test.api'
参数化测试
使用不同参数运行相同的测试逻辑,减少代码重复,提高测试覆盖率:
import pytest
# Python pytest 参数化示例
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("World", "WORLD"),
("", ""),
("123", "123"),
("Hello World", "HELLO WORLD"),
])
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
# 使用 ids 为测试用例命名
@pytest.mark.parametrize("value,expected", [
(0, "zero"),
(1, "positive"),
(-1, "negative"),
], ids=["zero_case", "positive_case", "negative_case"])
def test_with_ids(value, expected):
assert classify(value) == expected
// Java JUnit 5 参数化示例
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
class CalculatorTest {
@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));
}
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "test"})
void testNotBlank(String value) {
assertFalse(value.isBlank());
}
}
// JavaScript Jest 参数化示例
describe('Calculator', () => {
// 使用 test.each 进行参数化
test.each([
[1, 1, 2],
[2, 3, 5],
[10, 20, 30],
])('add(%i, %i) should return %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
// 对象参数
test.each([
{ input: 'hello', expected: 'HELLO' },
{ input: 'world', expected: 'WORLD' },
])('uppercase($input)', ({ input, expected }) => {
expect(input.toUpperCase()).toBe(expected);
});
});
测试覆盖率
覆盖率类型
覆盖率类型详解:
| 类型 | 说明 | 强度 |
|---|---|---|
| 语句覆盖 | 每行代码至少执行一次 | 基础 |
| 分支覆盖 | 每个 if 的真假分支都执行 | 中等 |
| 函数覆盖 | 每个函数至少调用一次 | 基础 |
| 条件覆盖 | 每个条件的真假值都测试 | 较强 |
| 路径覆盖 | 所有可能的执行路径都测试 | 最强 |
覆盖率工具
# Python - pytest-cov
pip install pytest-cov
pytest --cov=myapp --cov-report=html
# 查看未覆盖的行
pytest --cov=myapp --cov-report=term-missing
# 设置覆盖率阈值
pytest --cov=myapp --cov-fail-under=80
# JavaScript - Jest
jest --coverage
# 在配置中设置阈值
# jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
},
},
};
# Java - JaCoCo
./mvnw jacoco:report
# Go
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
覆盖率目标
覆盖率数字的正确理解:
高覆盖率不等于高质量测试。覆盖率衡量的是代码执行,不是测试质量。关键在于测试是否有效验证了代码行为。
# 高覆盖率但不测试逻辑的例子
def add(a, b):
return a + b
def test_add_high_coverage():
add(1, 2) # 执行了代码,但没有断言!
# 覆盖率100%,但测试无效
# 有效测试的例子
def test_add_returns_correct_sum():
result = add(1, 2)
assert result == 3 # 验证逻辑正确性
建议的覆盖率目标:
| 代码类型 | 建议覆盖率 | 原因 |
|---|---|---|
| 核心业务逻辑 | 90%+ | 关键路径,故障影响大 |
| 通用工具类 | 80%+ | 被多处使用,需要稳定 |
| API 层 | 70-80% | 关注接口契约 |
| UI 组件 | 60-70% | 关注交互行为 |
| 配置/启动代码 | 可忽略 | 通常难以测试,风险低 |
测试组织
目录结构
project/
├── src/
│ ├── calculator.py
│ └── user_service.py
└── tests/
├── __init__.py
├── conftest.py # pytest 配置和共享 fixtures
├── test_calculator.py
├── test_user_service.py
└── fixtures/
└── test_data.json
测试类组织
使用测试类组织相关测试,提高可读性:
# 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
def test_add_zero(self, calculator):
"""测试加零"""
assert calculator.add(5, 0) == 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)
Fixtures 使用
pytest 的 fixtures 提供了强大的测试数据管理能力:
# conftest.py - 共享 fixtures
import pytest
from myapp import create_app, db
@pytest.fixture(scope='session')
def app():
"""创建测试应用"""
app = create_app(config='testing')
yield app
@pytest.fixture(scope='function')
def db_session(app):
"""创建数据库会话"""
with app.app_context():
db.create_all()
session = db.session
yield session
session.rollback()
db.drop_all()
@pytest.fixture
def sample_user(db_session):
"""创建示例用户"""
user = User(username="testuser", email="[email protected]")
db_session.add(user)
db_session.commit()
return user
Fixture 作用域:
| 作用域 | 生命周期 | 适用场景 |
|---|---|---|
function | 每个测试函数 | 需要隔离的测试数据(默认) |
class | 每个测试类 | 类中测试共享设置 |
module | 每个模块 | 模块级别共享数据 |
session | 整个测试会话 | 昂贵资源初始化 |
各语言单元测试示例
Python (pytest)
# 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("除数不能为零")
return a / b
# test_calculator.py
import pytest
from calculator import Calculator
class TestCalculator:
"""计算器测试"""
@pytest.fixture
def calc(self):
"""创建计算器实例"""
return Calculator()
def test_add_positive_numbers(self, calc):
"""测试正数相加"""
assert calc.add(2, 3) == 5
def test_add_negative_numbers(self, calc):
"""测试负数相加"""
assert calc.add(-2, -3) == -5
def test_divide_normal(self, calc):
"""测试正常除法"""
assert calc.divide(10, 2) == 5
def test_divide_by_zero(self, calc):
"""测试除以零抛出异常"""
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(10, 0)
Java (JUnit 5)
// 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("除数不能为零");
}
return a / b;
}
}
// CalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("计算器测试")
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Nested
@DisplayName("加法测试")
class AdditionTests {
@Test
@DisplayName("两个正数相加")
void testAddPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}
@Test
@DisplayName("两个负数相加")
void testAddNegativeNumbers() {
assertEquals(-5, calculator.add(-2, -3));
}
}
@Nested
@DisplayName("除法测试")
class DivisionTests {
@Test
@DisplayName("正常除法")
void testDivideNormal() {
assertEquals(5.0, calculator.divide(10, 2));
}
@Test
@DisplayName("除以零抛出异常")
void testDivideByZero() {
assertThrows(IllegalArgumentException.class, () -> {
calculator.divide(10, 0);
});
}
}
}
JavaScript (Jest)
// calculator.js
class Calculator {
add(a, b) {
return a + b;
}
divide(a, b) {
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
}
module.exports = Calculator;
// calculator.test.js
const Calculator = require('./calculator');
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
describe('add', () => {
test('should return sum of two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('should return sum of two negative numbers', () => {
expect(calc.add(-2, -3)).toBe(-5);
});
});
describe('divide', () => {
test('should return correct result', () => {
expect(calc.divide(10, 2)).toBe(5);
});
test('should throw error when dividing by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('除数不能为零');
});
});
});
Go
// calculator.go
package calculator
import "errors"
// Add 加法
func Add(a, b int) int {
return a + b
}
// Divide 除法
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
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) {
t.Run("normal division", func(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %f; want 5", result)
}
})
t.Run("divide by zero", func(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
})
}
// 表驱动测试 - Go 的惯用方式
func TestAddTableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"zero", 0, 5, 5},
{"mixed signs", -2, 3, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
单元测试最佳实践
DO(应该做)
-
保持测试简单
- 每个测试只验证一个概念
- 避免测试中出现复杂的逻辑
- 测试代码应该比被测代码更简单
-
使用描述性名称
- 测试名称应该说明测试的内容
- 当测试失败时,名称应该帮助定位问题
- 避免使用
test1、test2这样的名称
-
保持测试独立
- 测试之间不应该相互依赖
- 可以单独运行任何一个测试
- 测试执行顺序不应该影响结果
-
测试边界条件
- 空值、零值、最大值、最小值
- 异常和错误情况
- 边界附近的值
-
使用 Given-When-Then 或 AAA 模式
- 使测试结构清晰
- 便于理解和维护
DON'T(不应该做)
-
不要测试私有方法
- 通过公共接口测试私有方法的行为
- 私有方法是实现细节,测试会很脆弱
-
不要依赖外部资源
- 使用 Mock/Stub 替代数据库、网络等
- 保持测试快速和稳定
-
不要忽略失败的测试
- 失败的测试应该立即修复
- 注释掉的测试应该被删除或修复
-
不要写过于复杂的测试
- 如果测试需要很多设置,可能是设计问题
- 考虑重构被测代码
常见问题
1. 测试太多还是太少?
关注点:
- 关注业务逻辑和复杂逻辑
- 简单的 getter/setter 通常不需要测试
- 优先测试容易出错和关键的代码
经验法则:测试的价值应该大于编写和维护测试的成本。
2. 如何处理遗留代码?
策略:
- 在修改前添加 characterization tests(特征测试)
- 逐步增加覆盖率
- 重构和测试并行进行
- 优先测试修改的部分
3. 测试数据如何管理?
推荐方法:
- 使用 Factory/Builder 模式创建测试数据
- 使用 Fixture 共享通用设置
- 避免在测试中硬编码大量数据
- 测试数据应该易于理解和维护
练习
- 为一个简单的计算器类编写完整的单元测试,包括边界条件
- 使用 Mock 测试一个依赖外部 API 的服务
- 为边界条件编写测试用例
- 实现参数化测试,覆盖多种输入场景
- 配置测试覆盖率报告,达到 80% 以上
参考资源
- 单元测试的艺术 - Roy Osherove
- 测试驱动开发 - Kent Beck
- Google 测试最佳实践
- pytest 官方文档
- JUnit 5 用户指南