测试最佳实践
本章节总结软件测试的最佳实践,帮助你建立高质量的测试体系。
测试策略
测试金字塔
/\
/ \ E2E 测试(少量)- 验证完整用户流程
/____\ 成本高、执行慢、覆盖率低
/ \
/________\ 集成测试(中等)- 验证组件交互
/ \ 成本中等、执行中等
/____________\
/ \ 单元测试(大量)- 验证业务逻辑
/________________\ 成本低、执行快、覆盖率高
比例建议:
- 单元测试:70%
- 集成测试:20%
- E2E 测试:10%
测试四象限
┌─────────────────────┬─────────────────────┐
│ 支持开发团队 │ 评价产品 │
│ (技术导向) │ (业务导向) │
├─────────────────────┼─────────────────────┤
│ Q1: 单元测试 │ Q2: 功能测试 │
│ 组件测试 │ 验收测试 │
│ • 快速反馈 │ • 验证业务价值 │
│ • 支持重构 │ • 自动化验收 │
├─────────────────────┼─────────────────────┤
│ Q3: 探索性测试 │ Q4: 性能测试 │
│ 用户场景测试 │ 安全测试 │
│ • 发现意外问题 │ • 非功能需求 │
│ • 用户体验 │ • 系统特性 │
└─────────────────────┴─────────────────────┘
测试设计原则
FIRST 原则
好的单元测试应该遵循 FIRST 原则:
| 原则 | 说明 | 实践 |
|---|---|---|
| Fast | 快速 | 测试应该在毫秒级完成 |
| Independent | 独立 | 测试之间不应相互依赖 |
| Repeatable | 可重复 | 在任何环境执行结果一致 |
| Self-validating | 自验证 | 测试结果应该明确(通过/失败) |
| Timely | 及时 | 与生产代码一起编写 |
RIGHT BICEP 原则
测试应该覆盖以下场景:
- Right:正确输入得到正确结果
- Boundary:边界条件
- Inverse:反向关系验证
- Cross-check:交叉检查结果
- Error:错误条件
- Performance:性能特征
示例
def test_calculator_right_bicep():
calc = Calculator()
# Right: 正确输入
assert calc.add(2, 3) == 5
# Boundary: 边界条件
assert calc.add(0, 0) == 0
assert calc.add(-1, 1) == 0
# Inverse: 反向验证
result = calc.add(5, 3)
assert calc.subtract(result, 3) == 5
# Cross-check: 交叉检查
assert calc.add(2, 3) == calc.add(3, 2)
# Error: 错误条件
with pytest.raises(ValueError):
calc.divide(10, 0)
# Performance: 性能
import time
start = time.time()
for _ in range(1000):
calc.add(1, 1)
duration = time.time() - start
assert duration < 0.01 # 10ms 内完成
测试代码质量
测试代码也是代码
测试代码应该与生产代码一样保持高质量:
# ❌ 糟糕的测试代码
def test1():
x = calc(1, 2, 3, 4, 5)
assert x == 15
# ✅ 好的测试代码
def test_calculate_sum_of_multiple_numbers():
"""测试多个数字求和"""
numbers = [1, 2, 3, 4, 5]
result = calculator.sum(numbers)
assert result == 15
DRY 原则在测试中的应用
# ❌ 重复代码
def test_create_user_with_valid_data():
user = User()
user.username = "testuser"
user.email = "[email protected]"
user.password = "password123"
user.save()
assert user.id is not None
def test_update_user_with_valid_data():
user = User()
user.username = "testuser"
user.email = "[email protected]"
user.password = "password123"
user.save()
user.email = "[email protected]"
user.save()
assert user.email == "[email protected]"
# ✅ 使用 Fixture 消除重复
@pytest.fixture
def valid_user():
user = User()
user.username = "testuser"
user.email = "[email protected]"
user.password = "password123"
user.save()
return user
def test_create_user_with_valid_data(valid_user):
assert valid_user.id is not None
def test_update_user_with_valid_data(valid_user):
valid_user.email = "[email protected]"
valid_user.save()
assert valid_user.email == "[email protected]"
避免过度使用 DRY
有时重复的测试代码更清晰:
# ✅ 适度的重复使测试更清晰
def test_add_positive_numbers():
assert calculator.add(2, 3) == 5
def test_add_negative_numbers():
assert calculator.add(-2, -3) == -5
def test_add_mixed_numbers():
assert calculator.add(-2, 3) == 1
测试数据管理
测试数据工厂
# factories.py
import factory
from faker import Faker
fake = Faker()
class UserFactory(factory.Factory):
class Meta:
model = User
username = factory.LazyAttribute(lambda x: fake.user_name())
email = factory.LazyAttribute(lambda x: fake.email())
first_name = factory.LazyAttribute(lambda x: fake.first_name())
last_name = factory.LazyAttribute(lambda x: fake.last_name())
is_active = True
class OrderFactory(factory.Factory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
total_amount = factory.LazyAttribute(lambda x: fake.pydecimal(
left_digits=4, right_digits=2, positive=True
))
status = "pending"
# 使用
@pytest.fixture
def user_with_orders():
user = UserFactory()
OrderFactory.create_batch(3, user=user)
return user
测试夹具(Fixture)
# conftest.py
import pytest
@pytest.fixture(scope="function")
def db_transaction(db):
"""每个测试在事务中运行,测试后回滚"""
yield db
db.rollback()
@pytest.fixture(scope="module")
def test_data_module():
"""模块级别的测试数据"""
data = load_test_data()
yield data
cleanup_test_data(data)
@pytest.fixture
def api_client():
"""API 测试客户端"""
from app import create_app
app = create_app(testing=True)
with app.test_client() as client:
yield client
测试组织
目录结构
project/
├── src/
│ ├── __init__.py
│ ├── models/
│ ├── services/
│ └── api/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享 fixture
│ ├── unit/ # 单元测试
│ │ ├── __init__.py
│ │ ├── test_models.py
│ │ └── test_services.py
│ ├── integration/ # 集成测试
│ │ ├── __init__.py
│ │ ├── test_database.py
│ │ └── test_api.py
│ ├── e2e/ # 端到端测试
│ │ ├── __init__.py
│ │ └── test_user_flow.py
│ └── fixtures/ # 测试数据
│ ├── users.json
│ └── orders.json
├── pytest.ini
└── setup.cfg
测试命名规范
# 测试文件命名
test_<module>.py # 测试对应模块
test_<feature>.py # 测试特定功能
# 测试类命名
class Test<UserService>: # 被测类名 + Test
class Test<Feature>: # 测试特定功能
# 测试方法命名
def test_<action>_<condition>_<expected_result>():
# 例如:
def test_create_user_with_valid_data_returns_user():
def test_create_user_with_duplicate_email_raises_error():
def test_calculate_total_with_empty_cart_returns_zero():
Mock 最佳实践
何时使用 Mock
| 场景 | 建议使用 Mock | 原因 |
|---|---|---|
| 外部 HTTP 调用 | ✅ | 避免网络依赖,提高速度 |
| 数据库访问 | ✅/❌ | 单元测试用 Mock,集成测试用真实数据库 |
| 文件系统操作 | ✅ | 提高速度,避免副作用 |
| 当前时间 | ✅ | 确保测试可重复 |
| 随机数生成 | ✅ | 确保测试可重复 |
| 复杂依赖 | ✅ | 隔离被测单元 |
Mock 最佳实践
# ✅ 只 Mock 你拥有的接口
@patch('myapp.services.PaymentGateway') # 好
@patch('requests.post') # 避免,除非你封装了 requests
# ✅ 验证行为,不只是状态
def test_payment_service_charges_correctly():
gateway = Mock()
service = PaymentService(gateway)
service.process_payment(100, "user123")
# 验证行为
gateway.charge.assert_called_once_with(
amount=100,
user_id="user123",
currency="USD"
)
# ✅ 使用 spec 确保接口兼容
from unittest.mock import create_autospec
def test_with_spec():
gateway = create_autospec(PaymentGateway)
service = PaymentService(gateway)
service.process_payment(100, "user123")
# 如果方法签名不匹配会报错
gateway.charge.assert_called_once()
测试覆盖率
覆盖率目标
┌─────────────────────────────────────────────────────────────┐
│ 覆盖率目标建议 │
├─────────────────────────────────────────────────────────────┤
│ 语句覆盖 │ 最低 80% │ 核心业务 100% │
│ 分支覆盖 │ 最低 70% │ 核心业务 90% │
│ 函数覆盖 │ 最低 80% │ 核心业务 100% │
│ 行覆盖 │ 最低 80% │ 核心业务 100% │
└─────────────────────────────────────────────────────────────┘
覆盖率配置
# pytest.ini
[pytest]
addopts =
--cov=src
--cov-report=term-missing
--cov-report=html
--cov-fail-under=80
[coverage:run]
source = src
omit =
*/tests/*
*/test_*
*/__pycache__/*
*/migrations/*
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
不要追求 100% 覆盖率
# ❌ 为了覆盖率而测试
def test_simple_getter():
obj = MyClass()
assert obj.simple_getter() == obj._value # 无意义
# ✅ 关注有意义的测试
def test_complex_business_logic():
# 测试复杂的业务规则
pass
持续集成中的测试
CI 测试流水线
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run linting
run: |
flake8 src tests
black --check src tests
- name: Run unit tests
run: pytest tests/unit -v --cov=src --cov-report=xml
- name: Run integration tests
run: pytest tests/integration -v
env:
DATABASE_URL: postgresql://postgres:postgres@localhost/postgres
REDIS_URL: redis://localhost:6379/0
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
测试分层执行
# 快速反馈(本地开发)
pytest tests/unit -x # 遇到第一个失败就停止
# 完整测试(提交前)
pytest tests/unit tests/integration
# 完整回归(CI/CD)
pytest tests/
测试文档
测试即文档
def test_user_can_register_with_valid_email():
"""
用户可以使用有效邮箱注册。
前置条件:
- 邮箱未被注册
步骤:
1. 访问注册页面
2. 输入有效邮箱
3. 输入密码
4. 点击注册按钮
预期结果:
- 注册成功
- 收到确认邮件
- 可以登录
"""
# 测试代码
测试报告
# pytest-html 生成美观报告
pytest --html=report.html --self-contained-html
# pytest-json 生成 JSON 报告
pytest --json-report --json-report-file=report.json
常见反模式
1. 测试代码重复生产代码逻辑
# ❌ 错误:测试重复实现
def test_discount_calculation():
price = 100
discount = 0.2
expected = price * (1 - discount) # 重复实现
result = calculate_discount(price, discount)
assert result == expected
# ✅ 正确:使用硬编码期望值
def test_discount_calculation():
result = calculate_discount(100, 0.2)
assert result == 80 # 明确的期望值
2. 测试依赖执行顺序
# ❌ 错误:测试相互依赖
class TestUser:
def test_create(self):
self.user_id = create_user() # 保存状态
def test_get(self):
user = get_user(self.user_id) # 依赖上一个测试
assert user is not None
# ✅ 正确:每个测试独立
def test_create_user():
user_id = create_user()
assert get_user(user_id) is not None
def test_get_user():
user_id = create_user() # 自己准备数据
user = get_user(user_id)
assert user is not None
3. 过度使用 Mock
# ❌ 错误:Mock 太多实现细节
@patch('app.services.UserService._validate_email')
@patch('app.services.UserService._hash_password')
@patch('app.services.UserService._send_welcome_email')
def test_create_user(m1, m2, m3):
# 测试变得脆弱
# ✅ 正确:Mock 边界,不是实现
def test_create_user():
email_service = Mock()
user_service = UserService(email_service=email_service)
user = user_service.create_user("[email protected]", "password")
assert user.email == "[email protected]"
email_service.send_welcome.assert_called_once()
测试驱动开发(TDD)
TDD 循环
┌─────────────┐
│ 编写测试 │◀─────────────────┐
└──────┬──────┘ │
│ │
▼ │
┌─────────────┐ │
│ 运行测试 │ │
│ (应该失败) │ │
└──────┬──────┘ │
│ │
▼ │
┌─────────────┐ │
│ 编写代码 │ │
│ (最少实现) │ │
└──────┬──────┘ │
│ │
▼ │
┌─────────────┐ │
│ 运行测试 │ │
│ (应该通过) │ │
└──────┬──────┘ │
│ │
▼ │
┌─────────────┐ │
│ 重构 │──────────────────┘
└─────────────┘
TDD 示例
# 第 1 步:编写失败的测试
def test_fizzbuzz_returns_fizz_for_multiple_of_3():
assert fizzbuzz(3) == "Fizz"
# 第 2 步:编写最少代码
def fizzbuzz(n):
return "Fizz"
# 第 3 步:添加更多测试
def test_fizzbuzz_returns_buzz_for_multiple_of_5():
assert fizzbuzz(5) == "Buzz"
# 第 4 步:更新代码
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
# 第 5 步:继续添加测试和代码...
性能测试
基准测试
# Python 示例
import pytest
import time
def test_performance_requirement():
"""测试性能要求"""
start = time.perf_counter()
result = process_large_dataset()
duration = time.perf_counter() - start
assert duration < 1.0 # 必须在 1 秒内完成
# Go 示例
func BenchmarkProcessData(b *testing.B) {
data := generateTestData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ProcessData(data)
}
}
总结
测试检查清单
- 测试命名清晰,描述行为
- 每个测试只验证一个概念
- 测试之间相互独立
- 测试执行速度快
- 使用合适的测试替身
- 覆盖边界条件和错误情况
- 保持测试代码整洁
- 定期重构测试代码
- 维护测试文档
- 在 CI 中自动运行测试
关键要点
- 测试是开发的一部分,不是额外工作
- 好的测试比多的测试更重要
- 测试代码需要与生产代码同等对待
- 自动化是测试成功的关键
- 持续改进测试策略和实践