跳到主要内容

测试最佳实践

本章节总结软件测试的最佳实践,帮助你建立高质量的测试体系。

测试策略

测试金字塔

            /\
/ \ 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 中自动运行测试

关键要点

  1. 测试是开发的一部分,不是额外工作
  2. 好的测试多的测试更重要
  3. 测试代码需要与生产代码同等对待
  4. 自动化是测试成功的关键
  5. 持续改进测试策略和实践

参考资源