15 - 测试
第 15 章:测试
掌握 pytest 测试框架、fixture、参数化、Mock 和测试驱动开发。
15.1 测试基础
15.1.1 为什么需要测试?
| 测试类型 | 目的 | 工具 |
|---|---|---|
| 单元测试 | 验证单个函数/类 | pytest, unittest |
| 集成测试 | 验证模块协作 | pytest |
| 端到端测试 | 验证完整流程 | Selenium, Playwright |
| 性能测试 | 验证性能指标 | pytest-benchmark |
15.1.2 pytest 基本用法
# calculator.py
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("除数不能为零")
return a / b
# test_calculator.py
from calculator import add, divide
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 0) == 0
def test_divide():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
import pytest
with pytest.raises(ValueError, match="除数不能为零"):
divide(1, 0)
# 运行测试
$ pytest
$ pytest -v # 详细输出
$ pytest test_calculator.py # 指定文件
$ pytest -k "add" # 匹配测试名
15.2 pytest Fixture
15.2.1 基本 fixture
import pytest
from pathlib import Path
@pytest.fixture
def sample_data():
"""提供测试数据。"""
return {"name": "Alice", "age": 30}
@pytest.fixture
def temp_dir(tmp_path):
"""使用 pytest 内置的 tmp_path。"""
data_file = tmp_path / "data.txt"
data_file.write_text("hello")
return data_file
def test_sample_data(sample_data):
assert sample_data["name"] == "Alice"
def test_file_content(temp_dir):
assert temp_dir.read_text() == "hello"
15.2.2 fixture 作用域
@pytest.fixture(scope="function") # 默认,每个测试函数执行一次
def db_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture(scope="module") # 整个模块执行一次
def database():
db = setup_database()
yield db
teardown_database(db)
@pytest.fixture(scope="session") # 整个测试会话执行一次
def app():
return create_app()
@pytest.fixture(scope="class") # 每个测试类执行一次
def shared_resource():
...
15.2.3 yield fixture(清理资源)
@pytest.fixture
def user_service():
# setup
service = UserService()
service.connect()
yield service # 测试执行期间使用
# teardown
service.disconnect()
service.cleanup()
def test_create_user(user_service):
user = user_service.create("Alice")
assert user.name == "Alice"
15.3 参数化测试
import pytest
# 基本参数化
@pytest.mark.parametrize("input, expected", [
("hello", "HELLO"),
("world", "WORLD"),
("Python", "PYTHON"),
])
def test_upper(input, expected):
assert input.upper() == expected
# 多参数组合
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert x * y > 0
# 自定义测试 ID
@pytest.mark.parametrize("input, expected", [
pytest.param(1, 2, id="one"),
pytest.param(2, 4, id="two"),
], ids=["小写", "大写"])
def test_double(input, expected):
assert input * 2 == expected
15.4 Mock
15.4.1 基本用法
from unittest.mock import Mock, patch, MagicMock
# 创建 Mock 对象
mock_api = Mock()
mock_api.get_user.return_value = {"name": "Alice"}
result = mock_api.get_user(1)
assert result == {"name": "Alice"}
mock_api.get_user.assert_called_once_with(1)
15.4.2 patch 装饰器
# service.py
import requests
def get_user(user_id: int) -> dict:
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
# test_service.py
from unittest.mock import patch, MagicMock
from service import get_user
@patch("service.requests.get")
def test_get_user(mock_get):
# 设置 mock 返回值
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = get_user(1)
assert result["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
15.4.3 Mock 常用方法
mock = Mock()
# 设置返回值
mock.method.return_value = 42
# 设置副作用
mock.method.side_effect = ValueError("错误")
# 多次调用不同返回值
mock.method.side_effect = [1, 2, 3]
# 验证调用
mock.method(1, 2, key="value")
mock.method.assert_called_once_with(1, 2, key="value")
mock.method.assert_called()
mock.method.assert_called_with(1, 2, key="value")
assert mock.method.call_count == 1
15.5 测试覆盖率
# 安装
$ pip install pytest-cov
# 运行并生成覆盖率报告
$ pytest --cov=myproject --cov-report=term-missing
$ pytest --cov=myproject --cov-report=html # HTML 报告
# pyproject.toml 配置
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing"
[tool.coverage.run]
source = ["src"]
omit = ["tests/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
15.6 hypothesis(属性测试)
from hypothesis import given, strategies as st
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
assert a + b == b + a
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
assert sorted(sorted(lst)) == sorted(lst)
@given(st.text(min_size=1))
def test_encode_decode(s):
assert s.encode("utf-8").decode("utf-8") == s
15.7 测试目录结构
myproject/
├── src/
│ └── myproject/
│ ├── __init__.py
│ ├── models.py
│ └── services.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享 fixture
│ ├── test_models.py
│ └── test_services.py
└── pyproject.toml
# tests/conftest.py(共享 fixture)
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Alice", "email": "[email protected]"}
@pytest.fixture
def db_session():
session = create_session()
yield session
session.rollback()
session.close()
15.8 TDD(测试驱动开发)
TDD 流程:
1. 🔴 Red:编写一个失败的测试
2. 🟢 Green:编写最少代码使测试通过
3. 🔵 Refactor:重构代码,保持测试通过
# 第一步:写测试
def test_fizzbuzz():
assert fizzbuzz(3) == "Fizz"
assert fizzbuzz(5) == "Buzz"
assert fizzbuzz(15) == "FizzBuzz"
assert fizzbuzz(7) == "7"
# 第二步:实现
def fizzbuzz(n: int) -> str:
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
# 第三步:重构
def fizzbuzz(n: int) -> str:
parts = []
if n % 3 == 0:
parts.append("Fizz")
if n % 5 == 0:
parts.append("Buzz")
return "".join(parts) or str(n)
15.9 pytest 常用插件
| 插件 | 用途 |
|---|---|
| pytest-cov | 覆盖率 |
| pytest-xdist | 并行测试 |
| pytest-asyncio | 异步测试 |
| pytest-mock | Mock 支持 |
| pytest-django | Django 测试 |
| pytest-httpx | httpx Mock |
| pytest-benchmark | 性能基准 |
# 并行运行测试
$ pip install pytest-xdist
$ pytest -n auto # 自动使用所有 CPU 核心
15.10 注意事项
🔴 注意:
- 测试文件命名为
test_*.py或*_test.py - 测试函数命名为
test_* - 不要测试实现细节,测试行为
- Mock 外部依赖(网络、数据库、文件系统),不 Mock 被测代码
💡 提示:
- 使用
conftest.py共享 fixture - 使用
@pytest.mark.parametrize减少重复测试代码 - 使用
tmp_pathfixture 处理临时文件 - 保持测试独立,测试之间不应有依赖
📌 业务场景:
import pytest
from unittest.mock import AsyncMock, patch
from dataclasses import dataclass
@dataclass
class Order:
id: int
amount: float
status: str = "pending"
class OrderService:
def __init__(self, repo):
self.repo = repo
def create_order(self, amount: float) -> Order:
if amount <= 0:
raise ValueError("金额必须大于零")
order = Order(id=1, amount=amount)
return self.repo.save(order)
# 测试
class TestOrderService:
@pytest.fixture
def mock_repo(self):
repo = Mock()
repo.save.side_effect = lambda o: o
return repo
@pytest.fixture
def service(self, mock_repo):
return OrderService(mock_repo)
def test_create_order(self, service, mock_repo):
order = service.create_order(100.0)
assert order.amount == 100.0
assert order.status == "pending"
mock_repo.save.assert_called_once()
def test_create_order_negative_amount(self, service):
with pytest.raises(ValueError, match="金额必须大于零"):
service.create_order(-10)