测试不是质量 assurance,而是质量 feedback。好的测试让你「无所畏惧」地重构。本文建立完整的 Python 测试工程体系。
1. pytest 基础
1.1 为什么选择 pytest
| 特性 | unittest | pytest | 说明 |
|---|---|---|---|
| 断言写法 | self.assertEqual | assert a == b | 更直观 |
| Fixture | setUp/tearDown | @pytest.fixture | 更灵活 |
| 插件生态 | 少 | 丰富(200+) | 扩展性强 |
| 失败信息 | 基础 | 详细 diff | 调试友好 |
1.2 快速上手
pip install pytest pytest-asyncio
# test_calculator.py — 无需类,纯函数即可
def add(a: int, b: int) -> int:
return a + b
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, 5) == 5
运行:
pytest test_calculator.py -v # 详细输出
pytest -x # 遇到失败立即停止
pytest --tb=short # 短 traceback
pytest -k "test_add" # 按名称筛选
pytest --lf # 只运行上次失败的
pytest --sw # 失败时进入 pdb
2. Fixture:测试依赖管理
2.1 基础 Fixture
import pytest
@pytest.fixture
def sample_user():
"""创建一个测试用户,测试结束后自动清理"""
user = {"id": 1, "name": "Alice", "email": "alice@example.com"}
yield user
# teardown 代码(在 yield 之后执行)
print(f"Cleanup user {user['id']}")
@pytest.fixture
def db_connection():
"""数据库连接 fixture"""
conn = create_connection(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
yield conn
conn.close()
# 使用 fixture
def test_user_name(sample_user):
assert sample_user["name"] == "Alice"
def test_db_insert(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'Bob')")
result = db_connection.execute("SELECT * FROM users").fetchall()
assert len(result) == 1
2.2 Fixture 作用域
@pytest.fixture(scope="function") # 默认:每个测试函数
@pytest.fixture(scope="class") # 每个测试类
@pytest.fixture(scope="module") # 每个模块(文件)
@pytest.fixture(scope="session") # 整个测试会话(只执行一次)
def expensive_resource():
return load_heavy_model()
2.3 Fixture 组合与依赖
@pytest.fixture
def api_client():
from fastapi.testclient import TestClient
from app.main import app
return TestClient(app)
@pytest.fixture
def auth_headers(api_client): # fixture 依赖另一个 fixture
"""获取认证后的请求头"""
response = api_client.post("/token", data={"username": "test", "password": "test"})
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_protected_endpoint(api_client, auth_headers):
response = api_client.get("/users/me", headers=auth_headers)
assert response.status_code == 200
3. Parametrize:参数化测试
3.1 基本用法
import pytest
@pytest.mark.parametrize(
"input,expected",
[
("hello", "HELLO"),
("World", "WORLD"),
("", ""),
("123", "123"),
]
)
def test_uppercase(input, expected):
assert input.upper() == expected
# 多参数
@pytest.mark.parametrize(
"a,b,expected",
[
(1, 1, 2),
(2, 3, 5),
(10, 20, 30),
]
)
def test_add(a, b, expected):
assert a + b == expected
3.2 与 Fixture 结合
@pytest.fixture(params=["sqlite", "postgresql"])
def database(request):
if request.param == "sqlite":
return SQLiteDB(":memory:")
else:
return PostgreSQLDB("postgresql://test:test@localhost/test")
def test_insert(database): # 对每个 param 都运行
database.insert({"name": "test"})
assert database.count() == 1
4. Mock 与 Patch
4.1 unittest.mock 基础
from unittest.mock import Mock, patch, MagicMock
# Mock 对象
mock = Mock()
mock.return_value = 42
assert mock() == 42
# 记录调用
mock("hello", key="value")
assert mock.called
assert mock.call_args == (("hello",), {"key": "value"})
assert mock.call_count == 1
# 指定返回值序列
mock.side_effect = [1, 2, 3, Exception("done")]
assert mock() == 1
assert mock() == 2
assert mock() == 3
# mock() # 抛出 Exception
4.2 patch:替换导入
from unittest.mock import patch
import requests
# patch 方式 1:装饰器
@patch('mymodule.requests.get')
def test_fetch_data(mock_get):
mock_get.return_value.json.return_value = {"data": "test"}
result = mymodule.fetch_data("https://api.example.com")
assert result == {"data": "test"}
mock_get.assert_called_once_with("https://api.example.com")
# patch 方式 2:上下文管理器
def test_fetch_data_context():
with patch('mymodule.requests.get') as mock_get:
mock_get.return_value.status_code = 200
# ...
# patch 方式 3:手动
patcher = patch('mymodule.requests.get')
mock_get = patcher.start()
# ... 测试 ...
# patch 多个
@patch('mymodule.send_email')
@patch('mymodule.log_activity')
def test_user_registration(mock_log, mock_email):
...
4.3 Monkeypatch
pytest 内置的 monkeypatch 更适合简单替换:
def test_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert os.environ["API_KEY"] == "test-key-123"
def test_modify_function(monkeypatch):
def mock_add(a, b):
return 100
monkeypatch.setattr("mymodule.add", mock_add)
assert mymodule.add(1, 2) == 100 # 被替换了
5. 异步测试
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_fetch():
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get("https://httpbin.org/get") as resp:
assert resp.status == 200
data = await resp.json()
assert "url" in data
# Fixture 也支持 async
@pytest.fixture
async def async_db():
db = await create_async_db()
yield db
await db.close()
@pytest.mark.asyncio
async def test_async_insert(async_db):
await async_db.insert({"name": "test"})
result = await async_db.find_one({"name": "test"})
assert result["name"] == "test"
6. 覆盖率
pip install pytest-cov
# 运行测试 + 生成覆盖率报告
pytest --cov=src --cov-report=term-missing
pytest --cov=src --cov-report=html # 生成 htmlcov/ 目录
pytest --cov=src --cov-report=xml # 生成 coverage.xml(CI 使用)
配置 .coveragerc:
[run]
source = src
branch = True # 分支覆盖率
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
[html]
directory = htmlcov
覆盖率目标:
- 单元测试:≥ 80%
- 核心业务逻辑:≥ 90%
- 工具脚本:≥ 60%
7. 测试策略金字塔
/\
/ \ 端到端测试 (E2E) - 慢但全面 - 10%
/____\
/ \ 集成测试 - 验证组件交互 - 20%
/________\
/ \ 单元测试 - 快而精确 - 70%
7.1 单元测试原则(FIRST)
| 原则 | 说明 |
|---|---|
| Fast | < 1ms,无外部依赖 |
| Independent | 不依赖其他测试 |
| Repeatable | 任何环境结果一致 |
| Self-validating | 断言明确,不假手于人 |
| Timely | 与代码同步编写 |
7.2 集成测试模式
# 测试数据库集成
@pytest.fixture
def test_db():
import tempfile
db_path = tempfile.mktemp(suffix=".db")
db = create_db(db_path)
yield db
db.close()
os.unlink(db_path)
# 测试 API 集成
def test_create_user_integration(api_client, test_db):
response = api_client.post("/users", json={"name": "Alice", "email": "alice@example.com"})
assert response.status_code == 201
# 验证数据库状态
user = test_db.query(User).filter_by(email="alice@example.com").first()
assert user is not None
assert user.name == "Alice"
8. CI 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v2
- name: Sync dependencies
run: uv sync
- name: Run tests
run: uv run pytest -xvs --cov=src --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
延伸阅读
- Python 现代工具链 — ruff + uv 配置
- Python 并发与性能 — 并发代码的测试挑战
- Python Web 框架 — FastAPI TestClient
- GitHub Actions CI/CD — 完整流水线
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。
「python」更多文章
Python 部署与分发:Docker、PyPI 发布与可复现环境
Python 项目从开发到生产的完整部署路径:Docker 多阶段构建与镜像优化、uvicorn/gunicorn 服务器配置、pyinstaller/uv 打包独立可执行文件、PyPI 包发布流程、Nix 可复现环境。附带 Dockerfile 模板和 GitHub Actions 发布流水线。
Python 现代工具链:uv + ruff + mypy 全链路工程实践
Python 工具链现代化完整指南:uv(极速包管理+虚拟环境+Python 安装)、ruff(lint+format 一体化)、mypy/pyright 类型检查、pipx 工具安装、 hatch/poetry/pdm 项目管理、从 pip 到 uv 的迁移路径。附带 pyproject.toml 完整配置模板。
Python 数据科学与 AI:Pandas/Polars、PyTorch 推理与 ONNX 部署
Python 数据科学生态全景:Pandas vs Polars vs NumPy 选型与性能对比、PyTorch 模型推理与优化、Transformers pipeline 实战、ONNX 导出与跨平台推理、与 Rust(PyO3)互操作加速。覆盖从数据处理到生产部署的完整链路。