跳到主要内容

单元测试

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

什么是单元测试?

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

单元测试的特征

为什么需要单元测试?

单元测试带来的价值远远超过编写测试的时间成本:

  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

各阶段详解

阶段英文职责示例
准备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 模式
来源xUnitBDD(行为驱动开发)
适用场景技术团队技术团队 + 业务人员
表达方式技术术语业务语言
可读性更好

编写好的单元测试

测试命名规范

好的测试名称应该清晰描述测试的行为,当测试失败时,名称本身就能帮助定位问题:

# ❌ 不好的命名 - 无法理解测试目的
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(应该做)

  1. 保持测试简单

    • 每个测试只验证一个概念
    • 避免测试中出现复杂的逻辑
    • 测试代码应该比被测代码更简单
  2. 使用描述性名称

    • 测试名称应该说明测试的内容
    • 当测试失败时,名称应该帮助定位问题
    • 避免使用 test1test2 这样的名称
  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. 配置测试覆盖率报告,达到 80% 以上

参考资源