
pytest 实战指南:从基础到高效测试的完整工作流
面向 Python 开发者的 pytest 使用指南,从基础 fixture 管理到高级插件生态,涵盖测试组织、参数化、mock 技巧和 CI 集成
原创。面向 Python 开发者的 pytest 实战指南,从 fixture 管理到插件生态,帮你写出可持续维护的测试套件。
Python 的测试工具选了一圈,pytest 胜出不是偶然。它不像 unittest 那样需要你写一堆样板代码,也不像 doctest 那样只能做小规模验证。pytest 的核心哲学很直接:测试代码应该比被测代码更好写。
截至 2026 年 6 月,pytest 8.x 是当前稳定版本,相比旧版本在错误报告、参数化支持和 fixture 作用域管理上都有显著改进。
为什么 pytest 值得认真学
如果你之前只用过 assert 加几个 if 写测试,或者还在用 unittest 的 self.assertEqual 那一套,切换到 pytest 后最大的改变不是语法——而是思维方式。
pytest 的 fixture 系统让你不用在测试之间共享 setup/teardown 代码。参数化让你用一组测试数据跑遍所有场景。插件生态覆盖了从测试覆盖率到 HTTP mock 的方方面面。更重要的是,pytest 的错误报告会直接告诉你断言失败时两边的值分别是什么——不用再自己加 print 调试了。
基础设置
安装很简单,一行命令:
pip install pytest
验证安装:
pytest --version
对于新项目,建议在项目根目录创建一个 pyproject.toml 并添加 pytest 配置:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]这样 pytest 只会在 tests/ 目录下寻找以 test_ 开头的文件,避免了误扫描项目中的其他 Python 文件。
运行测试:
pytest
# 或者更详细的输出
pytest -v
pytest 默认会递归搜索所有匹配的测试文件并执行。-v 参数让每个测试名称都显示出来,方便定位失败项。
截至 2026-06-09,最新稳定版为 pytest 8.4,建议在 requirements.txt 中指定 pytest>=8.0 以使用最新的 fixture 解析改进和错误报告优化。
Fixture:测试的血肉
fixture 是 pytest 最核心的概念。它解决了测试中最常见的问题:准备测试数据、创建测试对象、清理测试环境。
一个简单的 fixture 示例:
import pytest
@pytest.fixture
def user_data():
return {"name": "Alice", "email": "alice@example.com"}
def test_user_creation(user_data):
assert user_data["name"] == "Alice"
assert "@" in user_data["email"]这个 fixture 每次测试都会重新创建 user_data,保证了测试隔离。
fixture 作用域
fixture 有四种作用域,从最常用到最不常用:
function(默认):每个测试函数都重新创建。适合大多数场景,隔离性最好。
@pytest.fixture(scope="function")
def temp_file(tmp_path):
file = tmp_path / "data.txt"
file.write_text("test data")
yield file
# teardown: pytest 会自动清理 tmp_pathclass:同一个类中的测试共享。适合需要相同状态的一组测试。
module:同一个模块中的所有测试共享。适合开销较大的资源——比如数据库连接。
session:整个测试会话共享一次。对于 HTTP 客户端、配置对象这类线程安全且无状态的对象非常有用。
@pytest.fixture(scope="session")
def db_connection():
conn = create_database_connection()
yield conn
conn.close()选择作用域的实用原则:先用默认的 function,当测试变慢到影响开发效率时,再考虑提升作用域。过早优化作用域会导致测试之间产生隐式依赖,排查问题时更头疼。
conftest.py:共享 fixture 的组织方式
conftest.py 是 pytest 的特有机制。放在某个目录下的 conftest.py,其中的 fixture 对该目录及其子目录下的所有测试文件可见。
典型的项目结构:
project/
├── src/
│ ├── models.py
│ └── services.py
├── tests/
│ ├── conftest.py # 全局 fixture
│ ├── test_models.py
│ └── services/
│ ├── conftest.py # services 专用的 fixture
│ └── test_payment.py
全局 conftest.py 放一些项目级的 fixture(比如测试数据库、HTTP 客户端),子目录的 conftest.py 放该模块专用的 fixture(比如 mock 的支付服务响应)。这种分层组织方式让 fixture 的作用范围清晰可见,不会出现"这个 fixture 是哪里来的"的困惑。
内置 fixture
pytest 自带了一些很实用的内置 fixture,不需要额外安装:
| fixture | 用途 | 作用域 |
|---|---|---|
tmp_path | 临时目录,测试结束后自动清理 | function |
tmp_path_factory | tmp_path 的会话级版本 | session |
capsys | 捕获 stdout/stderr 输出 | function |
monkeypatch | 修改对象、环境变量或导入路径 | function |
request | 获取当前测试的元信息 | function |
monkeypatch 是我用得最多的内置 fixture——测试中需要 mock 环境变量或替换某个函数时,用它比用 unittest.mock 更简洁:
def test_api_call(monkeypatch):
def mock_response(*args, **kwargs):
return {"status": "ok"}
monkeypatch.setattr("requests.get", mock_response)
result = call_api()
assert result["status"] == "ok"参数化测试:一组数据,全部覆盖
参数化是 pytest 减少重复代码的核心工具。同一段测试逻辑,换不同的输入和预期输出,一次性覆盖所有场景。
import pytest
@pytest.mark.parametrize("input_val,expected", [
(1, 1),
(2, 4),
(3, 9),
(10, 100),
(0, 0),
(-1, 1),
])
def test_square(input_val, expected):
assert input_val ** 2 == expected这相比 unittest 的方案(写一个循环遍历数据)的优势是:当某个输入失败时,你能立刻知道具体是哪个输入导致的,而不是只看到"循环中某次断言失败"。
组合参数化
多个参数化标记会组合成笛卡尔积——所有参数组合都会被测试到:
@pytest.mark.parametrize("auth_type", ["none", "basic", "token"])
@pytest.mark.parametrize("endpoint", ["/api/v1/users", "/api/v1/posts"])
def test_endpoint_auth(auth_type, endpoint):
# 会生成 3 × 2 = 6 个测试用例
...注意组合数不要太大。3 种认证方式 × 5 个端点 × 2 种数据格式 = 30 个测试用例,这个规模还 OK。但如果是 10 × 20 × 5 = 1000 个用例,测试速度会明显变慢,而且失败时排查也很痛苦。
标记:给测试打标签
@pytest.mark 可以在不修改测试内容的情况下附加元信息。
跳过测试
@pytest.mark.skip(reason="还没有实现")
def test_new_feature():
...
@pytest.mark.skipif(sys.version_info < (3, 10), reason="需要 Python 3.10+")
def test_match_statement():
...标记预期失败
当你知道某个测试在当前条件下会失败,但不想让它阻塞 CI 时:
@pytest.mark.xfail(reason="已知的 API 变化,等待修复")
def test_legacy_endpoint():
...xfail 标记的测试如果通过了,pytest 也会报告出来——这可能意味着你忘了移除过期的标记。
自定义标记
# 先在 pyproject.toml 注册
# [tool.pytest.ini_options]
# markers = [
# "slow: 运行较慢的测试",
# "integration: 需要外部服务的集成测试",
# ]
@pytest.mark.slow
def test_large_dataset():
...
# 运行时不跑慢测试
# pytest -m "not slow"
# 只跑集成测试
# pytest -m "integration"用标记做测试分类,比用目录来组织灵活得多。同一个文件里可以有快测试和慢测试,用 -m 参数在运行前过滤即可。
并发执行:让测试跑得更快
随着测试量增长,串行执行会越来越慢。pytest 有两个主流方案。
pytest-xdist:多进程并行
pip install pytest-xdist
pytest -n auto-n auto 会自动使用 CPU 核心数作为并行数。对于独立且无共享状态的测试,直接加这个参数就能显著提速。
但要注意:如果 fixture 是 session 作用域的,它的执行次数并不会减少——每个 worker 进程都会执行一次 session fixture。如果 session fixture 的开销很大(比如创建数据库),可以考虑用 pytest-xdist 的 --dist loadscope 参数,按测试文件分发而不是按测试用例分发。
pytest-split:跨 CI 任务分割
pip install pytest-split
pytest --store-report # 第一次运行,记录各组耗时
pytest --splits 3 --group 1 # 在 CI 中只跑第 1 组如果你的 CI 有多个并行 job,pytest-split 会根据历史运行时间智能分配测试用例,避免某个 job 跑完了而另一个还在跑的尴尬。
Mock 与外部依赖
现实世界的代码很少不依赖外部服务。pytest 在这方面的生态很成熟。
pytest-mock:更方便的 mock
pip install pytest-mockpytest-mock 提供了一个 mocker fixture,相比直接使用 unittest.mock,它的好处是自动清理——测试结束后 mock 会自动恢复,不用担心污染其他测试。
def test_send_email(mocker):
mock_smtp = mocker.patch("smtplib.SMTP")
send_notification("user@example.com", "Hello")
mock_smtp.assert_called_once()responses:HTTP mock
pip install responsesimport responses
@responses.activate
def test_api_request():
responses.get(
"https://api.example.com/users/1",
json={"id": 1, "name": "Alice"},
status=200,
)
result = get_user(1)
assert result["name"] == "Alice"responses 库拦截所有 requests 库发出的 HTTP 请求。未被注册的 URL 请求会被视为异常——这反而是一个安全网,确保测试不会意外发出真实的网络请求。
pytest-docker:真实依赖容器化
当测试需要真实数据库或消息队列时:
pip install pytest-docker@pytest.fixture(scope="session")
def postgres_service(docker_services):
docker_services.start("postgres")
public_port = docker_services.wait_for_service(
"postgres", 5432
)
return f"postgresql://user:pass@localhost:{public_port}/test"这个方案比 mock 更接近生产环境,但速度较慢。建议对核心业务逻辑做单元测试(用 mock),对关键业务流程做集成测试(用 Docker 容器)。
测试覆盖率
pip install pytest-cov
pytest --cov=src --cov-report=term-missing这会在终端显示每个源文件的覆盖率,并标出没被覆盖到的行号。对于新项目,建议从 60% 的覆盖率开始,逐步提升到 80% 以上。
覆盖率数据的作用不是追求 100%,而是帮你发现:哪段代码长期没人碰过?哪个 error handler 从未被测试过?覆盖率低的地方,往往是 bug 潜伏的地方。
在 CI 中可以设置阈值:
pytest --cov=src --cov-fail-under=70当覆盖率低于 70% 时,CI 会失败。
测试的组织策略
测试怎么组织,直接影响你维护测试的意愿。以下是几个实用原则:
按功能模块组织目录。 别把所有测试塞进一个文件。tests/ 下的子目录结构最好镜像 src/ 的结构。
测试文件只包含测试。 辅助函数、测试数据放在单独的模块里,不要在测试文件里定义和测试无关的工具函数。
一个测试只测一件事。 如果一个测试里有三个断言,前两个失败了你就看不到第三个的结果。用参数化把"一件事"拆成多个测试用例。
测试名要说明测试意图。 test_user_creation 比 test_user_1 好,test_user_creation_with_invalid_email 比前两者都好。测试名是自动生成的文档。
不要测试内部实现。 测试应该关注输入和输出,而不是测试函数的内部调用顺序或私有方法。否则重构时你会把大量时间花在更新测试上。
CI 集成
把 pytest 集成到 GitHub Actions 中:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest --cov=src --cov-report=xml
- uses: codecov/codecov-action@v4这个配置会在每次 push 和 PR 时运行测试。结合 pytest-cov 生成的 XML 报告和 Codecov,还可以在 PR 上看到覆盖率变化趋势。
常见陷阱
fixture 请求太多导致测试难以理解。 如果一个测试需要 5 个以上的 fixture,说明它可能在测太多东西。考虑合并 fixture,或者把测试拆小。
共享可变状态的 fixture。 如果 fixture 返回一个列表或字典,多个测试修改同一个对象会相互影响。确保 fixture 每次返回新对象,或者在 yield 之前做好深拷贝。
忽略 fixture 清理。 使用 yield 在 fixture 中做 teardown,确保数据库记录、临时文件等资源被及时释放。pytest 保证即使测试失败,teardown 代码也会执行。
过度 mock。 mock 了太多依赖,测试确实快了,但不再能验证代码是否真的能工作。一个实用的判断标准:如果你 mock 了 3 层以上的调用链,应该写一个集成测试来覆盖这个场景。
太依赖 --ignore 或 --exclude 来绕过慢测试。 这会让 CI 的测试覆盖率越来越低。更好的做法是给慢测试加 @pytest.mark.slow 标记,在 CI 中同时跑"全部测试"和"仅快速测试"两个 job。
总结
pytest 的强大不在于某个单独的功能,而在于这些功能组合起来后对开发效率的提升。fixture 管理测试资源、参数化消除重复、标记做分类、插件补充缺失的功能——每个部分单独看都很简单,但组合起来就能让测试从"不得不写的负担"变成"用来验证想法的工具"。
一个建议:不要一次性引入所有特性。从简单的 fixture 开始,当你觉得"这段代码写得好重复"时,再学习参数化。当你觉得"测试跑得太慢了"时,再引入并发执行。pytest 的学习曲线很平缓,你可以按需深入。
项目官方文档:docs.pytest.org,截至 2026-06-09 的最新文档涵盖 pytest 8.x 的所有特性。
© 2026 四月 · CC BY-NC-SA 4.0
原文链接:https://aprilzz.com/tutorials/pytest-practical-guide