Python 项目工程化实战(二):pytest 测试体系建设从 fixtures 到 CI 门槛 | Python 进阶
在 工程化实战(一) 里,我们用 pyproject.toml + uv + Ruff 搭好了现代 Python 项目的骨架。但一个项目要真正「工程化」,光有漂亮的目录结构和格式化还不够——没有测试的代码,每次改动都是在赌博。
本篇是工程化系列第二篇,专注 pytest 测试体系:从最基础的断言写起,一路讲到 fixtures 依赖注入、参数化、mock/patch、覆盖率统计,最后把测试接入 CI 并设置覆盖率门槛。读完你会拥有一套可以直接落地到生产项目的测试规范。
一、为什么是 pytest,而不是 unittest?
Python 标准库自带 unittest,但社区早已用脚投票选择了 pytest。看一段对比就明白差距:
# ── unittest 风格:样板代码多 ──────────────────────────────import unittest
class TestCalculator(unittest.TestCase): def setUp(self): self.calc = Calculator()
def test_add(self): self.assertEqual(self.calc.add(2, 3), 5) self.assertTrue(self.calc.add(0, 0) == 0) self.assertRaises(ValueError, self.calc.divide, 1, 0)
# ── pytest 风格:原生 assert,一目了然 ────────────────────def test_add(): calc = Calculator() assert calc.add(2, 3) == 5 assert calc.add(0, 0) == 0 with pytest.raises(ValueError): calc.divide(1, 0)pytest 的核心优势:
| 维度 | unittest | pytest |
|---|---|---|
| 断言语法 | self.assertEqual(a, b) 等几十个方法 | 原生 assert a == b,失败自动展示两边值 |
| 测试组织 | 必须继承 TestCase 类 | 普通函数即可,也支持类 |
| 前置/后置 | setUp / tearDown | fixture 依赖注入(更灵活、可组合) |
| 参数化 | 手写循环或 subTest | @pytest.mark.parametrize 一行搞定 |
| 插件生态 | 基本没有 | 极其丰富(cov/xdist/mock/asyncio…) |
| 兼容性 | — | 能直接运行 unittest 写的测试 |
结论:新项目直接用 pytest;老项目可以渐进迁移——pytest 能跑 unittest 的用例,无需一次性重写。
安装
# 承接第一篇,用 uv 添加到开发依赖uv add --dev pytest pytest-cov pytest-mock
# 验证uv run pytest --version# pytest 8.x.x二、断言与测试组织
2.1 assert 的「魔法」
pytest 会重写 assert 语句(assertion rewriting),失败时给出详细上下文,不需要你手写错误信息:
def test_assertion_intro(): result = {"name": "Alice", "age": 30} assert result["age"] == 31运行后 pytest 会精确告诉你哪里不对:
E AssertionError: assert 30 == 31E + where 30 = {'name': 'Alice', 'age': 30}['age']常用断言模式:
# 相等 / 布尔assert x == expectedassert isinstance(obj, MyClass)assert "key" in my_dictassert result is None
# 浮点数比较(不要直接用 ==)import pytestassert 0.1 + 0.2 == pytest.approx(0.3)assert value == pytest.approx(3.14159, rel=1e-3)
# 断言异常with pytest.raises(ValueError): int("not a number")
# 断言异常 + 检查消息with pytest.raises(ValueError, match=r"invalid literal"): int("abc")
# 捕获异常对象做进一步断言with pytest.raises(ValueError) as exc_info: raise ValueError("boom")assert "boom" in str(exc_info.value)
# 断言警告with pytest.warns(DeprecationWarning): old_function()2.2 测试的发现规则
pytest 按约定自动发现测试,无需注册:
- 文件:test_*.py 或 *_test.py- 类: Test 开头(且不能有 __init__ 方法)- 函数/方法:test 开头class TestUser: # ✅ Test 开头 def test_create(self): # ✅ test 开头 assert User("bob").name == "bob"
def test_default_role(self): assert User("bob").role == "guest"
def test_standalone(): # ✅ 顶层测试函数 assert 1 + 1 == 22.3 运行与筛选
uv run pytest # 运行全部uv run pytest tests/test_user.py # 运行单个文件uv run pytest tests/test_user.py::TestUser::test_create # 运行单个用例uv run pytest -k "create or role" # 按名称关键字筛选uv run pytest -m slow # 按 marker 筛选(见第六节)uv run pytest -v # 详细输出(列出每个用例)uv run pytest -x # 遇到第一个失败就停止uv run pytest --lf # 只重跑上次失败的(last-failed)uv run pytest -s # 不捕获 print 输出(调试用)三、fixtures:pytest 的灵魂
setUp/tearDown 那套「所有测试共用同一套前置」的模式太僵硬。pytest 用 fixture + 依赖注入 取而代之:测试函数只要把 fixture 名字写进参数,pytest 就会自动创建并注入。
3.1 最简 fixture
import pytest
@pytest.fixturedef sample_user(): """提供一个测试用的 User 对象。""" return User(name="Alice", age=30)
# 测试函数把 fixture 名字作为参数,pytest 自动注入def test_user_name(sample_user): assert sample_user.name == "Alice"
def test_user_age(sample_user): # 每个测试都会拿到「全新」的 sample_user(function 作用域) assert sample_user.age == 303.2 yield fixture:自动清理资源
需要「用完就清理」的资源(文件、连接、临时目录)用 yield——yield 之前是 setup,之后是 teardown,即使测试失败也保证执行:
@pytest.fixturedef db_connection(): conn = create_connection("sqlite:///:memory:") # setup conn.execute("CREATE TABLE users (id INT, name TEXT)") yield conn # 把资源交给测试 conn.close() # teardown:一定会执行
def test_insert(db_connection): db_connection.execute("INSERT INTO users VALUES (1, 'bob')") rows = db_connection.execute("SELECT * FROM users").fetchall() assert len(rows) == 13.3 作用域(scope):控制创建频率
fixture 默认每个测试函数都重建一次。对创建成本高的资源,可以扩大作用域:
@pytest.fixture(scope="session") # 整个测试会话只创建一次def app_client(): app = create_app(config="testing") client = app.test_client() yield client # 会话结束时清理
@pytest.fixture(scope="module") # 每个测试文件创建一次def large_dataset(): return load_csv("big_file.csv") # 加载大文件,多个测试共享| scope | 创建频率 | 典型用途 |
|---|---|---|
function(默认) | 每个测试函数 | 需要隔离的可变对象、mock |
class | 每个测试类 | 类内多个方法共享的只读数据 |
module | 每个 .py 文件 | 加载大文件、编译正则 |
package | 每个包 | 包级别共享资源 |
session | 整个测试会话 | 数据库连接、启动测试服务器 |
⚠️ 作用域越大,隔离性越弱。 如果 fixture 返回可变对象且测试会修改它,务必用
function作用域,否则一个测试的修改会污染后续测试。
3.4 工厂 fixture:需要参数时
如果测试需要「定制化」的对象,让 fixture 返回一个函数(工厂模式):
@pytest.fixturedef make_user(): created = []
def _make(name="test", age=20, role="guest"): user = User(name=name, age=age, role=role) created.append(user) return user
yield _make # 清理所有创建的对象 for user in created: user.delete()
def test_admin(make_user): admin = make_user(name="root", role="admin") # 定制参数 assert admin.role == "admin"
def test_multiple(make_user): u1 = make_user(name="a") u2 = make_user(name="b") # 一个测试里造多个对象 assert u1.name != u2.name3.5 fixture 依赖 fixture
fixture 之间可以互相依赖,pytest 会自动解析依赖链:
@pytest.fixturedef db_connection(): conn = create_connection("sqlite:///:memory:") yield conn conn.close()
@pytest.fixturedef user_repo(db_connection): # 依赖上面的 db_connection return UserRepository(db_connection)
@pytest.fixturedef saved_user(user_repo): # 依赖 user_repo return user_repo.save(User("alice"))
def test_find(user_repo, saved_user): found = user_repo.find_by_id(saved_user.id) assert found.name == "alice"3.6 autouse:自动应用的 fixture
某些 setup 想对所有测试自动生效(比如重置全局状态、清理缓存),用 autouse=True:
@pytest.fixture(autouse=True)def reset_singleton(): """每个测试前自动重置全局单例,无需在测试函数里声明。""" GlobalConfig.reset() yield GlobalCache.clear()3.7 内置常用 fixture
pytest 自带一批开箱即用的 fixture:
def test_tmp_file(tmp_path): # tmp_path 是一个 pathlib.Path,指向本次测试专属的临时目录 f = tmp_path / "config.json" f.write_text('{"key": "value"}') assert f.read_text() == '{"key": "value"}'
def test_env(monkeypatch): # monkeypatch:临时修改环境变量/属性,测试后自动还原 monkeypatch.setenv("API_KEY", "test-key") monkeypatch.setattr("app.config.DEBUG", True) assert os.environ["API_KEY"] == "test-key"
def test_capture(capsys): # capsys:捕获 stdout/stderr print("hello") captured = capsys.readouterr() assert captured.out == "hello\n"
def test_logging(caplog): # caplog:捕获日志 logging.getLogger().warning("something") assert "something" in caplog.textmonkeypatch 尤其常用——它能临时打补丁并在测试结束后自动还原,避免污染其他测试。
四、参数化测试:一个函数覆盖多组输入
不要为「同一逻辑、不同输入」写一堆重复的测试函数。@pytest.mark.parametrize 让你用一份代码跑多组数据:
@pytest.mark.parametrize("a, b, expected", [ (2, 3, 5), (0, 0, 0), (-1, 1, 0), (100, 200, 300),])def test_add(a, b, expected): assert add(a, b) == expected运行时 pytest 会把它展开成 4 个独立用例,任何一组失败都能精确定位:
test_add[2-3-5] PASSEDtest_add[0-0-0] PASSEDtest_add[-1-1-0] PASSEDtest_add[100-200-300] PASSED4.1 给用例起可读的 ID
@pytest.mark.parametrize("email, is_valid", [ ("user@example.com", True), ("invalid.email", False), ("", False),], ids=["正常邮箱", "缺少@符号", "空字符串"])def test_validate_email(email, is_valid): assert validate_email(email) is is_valid4.2 标记预期失败的用例
@pytest.mark.parametrize("value, expected", [ (10, 100), (5, 25), pytest.param(0, 1, marks=pytest.mark.xfail(reason="0 的处理有已知 bug")),])def test_square(value, expected): assert square(value) == expected4.3 多个参数化叠加(笛卡尔积)
@pytest.mark.parametrize("browser", ["chrome", "firefox"])@pytest.mark.parametrize("os", ["windows", "macos", "linux"])def test_matrix(browser, os): # 自动生成 2 × 3 = 6 个用例组合 assert run_on(browser, os) is True4.4 参数化 fixture
fixture 也能参数化——依赖它的每个测试都会对每个参数各跑一遍:
@pytest.fixture(params=["sqlite", "postgres", "mysql"])def database(request): db = connect(request.param) # request.param 拿到当前参数 yield db db.close()
def test_query(database): # 这个测试会自动针对 3 种数据库各跑一次 assert database.query("SELECT 1") is not None五、mock 与 patch:隔离外部依赖
单元测试的原则是「只测自己的逻辑」。对数据库、HTTP 请求、时间、随机数等外部依赖,要用 mock 替身隔离掉。推荐用 pytest-mock 提供的 mocker fixture(比原生 unittest.mock.patch 更简洁,且自动清理)。
5.1 打补丁的黄金法则
patch where it’s looked up, not where it’s defined. 在「被测代码查找该名字的地方」打补丁,而不是「它被定义的地方」。
这是 mock 最常见的坑。看例子:
from app.utils import fetch_data # 名字被绑定到 service 模块的命名空间
def get_user_count(): data = fetch_data("https://api.example.com/users") return len(data)# tests/test_service.py —— 正确 ✅def test_get_user_count(mocker): # patch 'app.service.fetch_data'(使用处),不是 'app.utils.fetch_data' mock_fetch = mocker.patch("app.service.fetch_data") mock_fetch.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
assert get_user_count() == 3 mock_fetch.assert_called_once_with("https://api.example.com/users")如果你 patch 了 app.utils.fetch_data,因为 service.py 已经通过 from ... import 把名字绑到了自己的命名空间,补丁不会生效——这就是「patch 不生效」问题的根源。
5.2 mock 的返回值与副作用
def test_mock_basics(mocker): m = mocker.Mock()
# 固定返回值 m.get_name.return_value = "Alice" assert m.get_name() == "Alice"
# side_effect:抛异常 m.connect.side_effect = ConnectionError("网络不可用") with pytest.raises(ConnectionError): m.connect()
# side_effect:每次调用返回不同值 m.next_id.side_effect = [1, 2, 3] assert m.next_id() == 1 assert m.next_id() == 2
# side_effect:用函数动态计算 m.double.side_effect = lambda x: x * 2 assert m.double(5) == 105.3 断言调用情况
def test_call_assertions(mocker): mock_send = mocker.patch("app.notifier.send_email")
notify_user("bob@example.com", "Hello")
mock_send.assert_called_once() # 恰好调用一次 mock_send.assert_called_once_with("bob@example.com", "Hello") # 参数校验 assert mock_send.call_count == 1 # 查看具体调用参数 args, kwargs = mock_send.call_args assert args[0] == "bob@example.com"5.4 常见外部依赖的 mock
# ── mock HTTP 请求(requests)─────────────────────────────def test_api_call(mocker): mock_get = mocker.patch("app.client.requests.get") mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {"result": "ok"}
assert call_api() == {"result": "ok"}
# ── mock 时间 ─────────────────────────────────────────────def test_time(mocker): mock_now = mocker.patch("app.service.datetime") mock_now.now.return_value = datetime(2026, 1, 1, 12, 0, 0) assert get_timestamp() == "2026-01-01 12:00:00"
# ── mock 文件读取 ─────────────────────────────────────────def test_read_config(mocker): mocker.patch("builtins.open", mocker.mock_open(read_data='{"debug": true}')) assert load_config()["debug"] is True
# ── spy:真实调用 + 记录(不替换实现)───────────────────────def test_spy(mocker): spy = mocker.spy(math, "sqrt") result = compute_something(16) spy.assert_called_once_with(16) assert spy.spy_return == 4.0用第三方库时也有专门的 mock 工具:HTTP 可以用
responses或respx(httpx),时间可以用freezegun——比手动 patch 更省事。
六、marker:给测试打标签
marker 让你给测试分类,然后按需运行或跳过。
6.1 内置 marker
import sys, pytest
@pytest.mark.skip(reason="功能还没实现")def test_future_feature(): ...
@pytest.mark.skipif(sys.version_info < (3, 12), reason="需要 Python 3.12+")def test_new_syntax(): ...
@pytest.mark.xfail(reason="已知 bug,修复前预期失败")def test_known_bug(): assert buggy_function() == "correct"6.2 自定义 marker
先在 pyproject.toml 注册(避免 warning),再使用:
# pyproject.toml —— 承接第一篇的配置[tool.pytest.ini_options]markers = [ "slow: 运行较慢的测试(用 -m 'not slow' 跳过)", "integration: 集成测试,需要外部服务", "smoke: 冒烟测试,最核心的用例",]@pytest.mark.slowdef test_heavy_computation(): ...
@pytest.mark.integrationdef test_real_database(): ...uv run pytest -m slow # 只跑慢测试uv run pytest -m "not slow" # 跳过慢测试(本地快速反馈)uv run pytest -m "smoke or integration" # 组合筛选七、conftest.py:共享 fixture 与配置
conftest.py 是 pytest 的「魔法文件」——放在里面的 fixture 无需 import 就能被同目录及子目录的所有测试使用。它是组织测试代码的核心。
tests/├── conftest.py # 全局 fixture(所有测试可用)├── test_models.py├── api/│ ├── conftest.py # 只对 api/ 下的测试生效│ └── test_endpoints.py└── db/ ├── conftest.py # 只对 db/ 下的测试生效 └── test_repository.py# tests/conftest.py —— 全局共享import pytestfrom app import create_app
@pytest.fixture(scope="session")def app(): """整个测试会话共享一个应用实例。""" app = create_app(config="testing") yield app
@pytest.fixturedef client(app): """每个测试一个全新的测试客户端。""" return app.test_client()
# 全局 hook:给所有测试自动加 markerdef pytest_collection_modifyitems(config, items): for item in items: if "integration" in item.nodeid: item.add_marker(pytest.mark.integration)# tests/api/conftest.py —— 仅 api 子目录import pytest
@pytest.fixturedef auth_headers(): return {"Authorization": "Bearer test-token"}# tests/api/test_endpoints.py —— 直接用,无需 importdef test_get_users(client, auth_headers): # client 来自全局 conftest resp = client.get("/api/users", headers=auth_headers) # auth_headers 来自 api/conftest assert resp.status_code == 200八、测试覆盖率:pytest-cov
覆盖率衡量「测试执行到了多少代码」。承接第一篇装好的 pytest-cov:
# 基本用法:统计 src 目录覆盖率uv run pytest --cov=src
# 显示未覆盖的具体行号(最实用)uv run pytest --cov=src --cov-report=term-missing
# 生成 HTML 报告(可视化,浏览器打开 htmlcov/index.html)uv run pytest --cov=src --cov-report=html
# 生成 XML 报告(给 CI / Codecov 用)uv run pytest --cov=src --cov-report=xml终端输出示例:
Name Stmts Miss Cover Missing-------------------------------------------------------src/app/__init__.py 3 0 100%src/app/service.py 45 5 89% 23-25, 41-42src/app/utils.py 20 0 100%-------------------------------------------------------TOTAL 68 5 93%Missing 列直接告诉你哪些行还没被测到——这是补测试的最佳指南。
8.1 在 pyproject.toml 里固化配置
[tool.pytest.ini_options]testpaths = ["tests"]addopts = [ "--tb=short", "--cov=src", "--cov-report=term-missing", "--cov-fail-under=80", # 覆盖率低于 80% 直接判定失败!]
[tool.coverage.run]source = ["src"]branch = true # 开启分支覆盖率(比行覆盖率更严格)omit = ["tests/*", "**/__main__.py"]
[tool.coverage.report]show_missing = trueexclude_lines = [ # 这些行不计入覆盖率统计 "pragma: no cover", "if __name__ == .__main__.:", "raise NotImplementedError", "if TYPE_CHECKING:",]8.2 覆盖率的正确认知
- 覆盖率不是越高越好,而是「关键路径必须覆盖」。 90% 覆盖率但漏掉核心支付逻辑,不如 75% 但核心全覆盖。
- 分支覆盖(branch=true)比行覆盖更有意义——它要求
if/else的两个分支都被测到。 - 不要为了刷覆盖率写无断言的测试(调用了但不 assert),那是自欺欺人。
- 新项目建议门槛 80%,核心库可以要求 90%+,把
--cov-fail-under写进 CI。
九、必备插件清单
pytest 的战斗力一半来自插件生态。用 uv add --dev 按需安装:
| 插件 | 作用 | 关键用法 |
|---|---|---|
| pytest-cov | 覆盖率统计 | --cov=src --cov-report=term-missing |
| pytest-mock | 提供 mocker fixture | mocker.patch(...),自动清理 |
| pytest-xdist | 多进程并行跑测试 | pytest -n auto(大幅提速) |
| pytest-asyncio | 测试 async 代码 | @pytest.mark.asyncio |
| pytest-randomly | 随机测试顺序 | 暴露测试间的隐藏依赖 |
| pytest-sugar | 更漂亮的进度输出 | 装上即生效 |
| pytest-timeout | 给测试设超时 | --timeout=10 防死循环 |
| freezegun | 冻结时间 | @freeze_time("2026-01-01") |
| responses / respx | mock HTTP | 拦截 requests / httpx |
9.1 并行加速(pytest-xdist)
uv add --dev pytest-xdistuv run pytest -n auto # 自动按 CPU 核数分配进程uv run pytest -n 4 # 指定 4 个进程注意:并行要求测试彼此独立。如果有测试依赖共享状态(如同一个数据库表),会随机失败——这恰恰说明测试写得不够隔离,
pytest-randomly也能帮你揪出这类问题。
9.2 测试 async 代码(pytest-asyncio)
承接前面的 asyncio 指南,异步函数这样测:
# [tool.pytest.ini_options]# asyncio_mode = "auto" # 自动识别 async 测试,无需每个都加装饰器
import pytest
@pytest.mark.asyncioasync def test_async_fetch(): result = await fetch_data_async("https://api.example.com") assert result["status"] == "ok"
@pytest.fixtureasync def async_client(): client = await create_async_client() yield client await client.close()十、接入 CI:把测试变成质量门槛
第一篇给了 CI 骨架,这里补全「测试 + 覆盖率门槛」的完整版本:
name: CI
on: push: branches: [main] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"]
steps: - uses: actions/checkout@v4
- name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true
- name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }}
- name: Install dependencies run: uv sync --frozen
- name: Lint (Ruff) run: | uv run ruff check src/ tests/ uv run ruff format --check src/ tests/
- name: Type check (mypy) run: uv run mypy src/
- name: Run tests with coverage run: uv run pytest -n auto --cov=src --cov-report=xml --cov-fail-under=80
- name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: file: ./coverage.xml fail_ci_if_error: false10.1 本地 pre-commit:提交前拦截
不想等 CI 才发现问题?用 pre-commit 在 git commit 时本地跑一遍:
repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.0 hooks: - id: ruff args: [--fix] - id: ruff-format
- repo: local hooks: - id: pytest-fast name: pytest (fast subset) entry: uv run pytest -m "not slow" -x -q language: system pass_filenames: false always_run: trueuv add --dev pre-commituv run pre-commit install # 安装 git hook# 之后每次 git commit 都会自动跑 ruff + 快速测试策略:pre-commit 只跑「快速子集」(
-m "not slow")保证提交流畅;完整测试(含慢测试、多 Python 版本)交给 CI。
十一、测试最佳实践清单
- 一个测试只验证一件事——失败时能立刻知道是哪里坏了。
- 测试命名要能读懂意图:
test_withdraw_fails_when_balance_insufficient胜过test_withdraw_2。 - 遵循 AAA 结构:Arrange(准备)→ Act(执行)→ Assert(断言),层次清晰。
- 测试要独立——不依赖执行顺序、不共享可变状态,用
pytest-randomly验证。 - 隔离外部依赖——数据库、网络、时间、随机数全部 mock 掉,单元测试要快且确定。
- 优先测行为,而非实现——测「输入 X 得到输出 Y」,而不是「内部调用了哪个私有方法」,否则重构就会误伤测试。
- 别忘了测边界和异常路径——空输入、None、越界、异常分支往往才是 bug 藏身处。
- 覆盖率关注关键路径,用分支覆盖,别刷无断言的假测试。
- fixture 管理资源用 yield,保证清理;作用域按成本和隔离性权衡。
- 测试跑得快——慢测试打
slowmarker,本地跳过、CI 并行(-n auto)。
AAA 结构示例
def test_withdraw_reduces_balance(): # Arrange:准备 account = Account(balance=100)
# Act:执行被测行为 account.withdraw(30)
# Assert:断言结果 assert account.balance == 70十二、工程化系列小结
| 环节 | 工具 | 本系列位置 |
|---|---|---|
| 项目结构 / 依赖 / 格式化 | pyproject.toml + uv + Ruff | 第一篇 |
| 测试体系 / 覆盖率 / CI 门槛 | pytest + pytest-cov + CI | 本篇(第二篇) |
到这里,你的项目已经拥有:统一配置、极速依赖管理、自动格式化、完整测试体系、CI 质量门槛——这就是 2026 年一个现代 Python 项目的工程化底座。剩下的类型检查(mypy 深入)、文档、打包发布 PyPI,会在后续篇章展开。
下一篇预告
Python 项目工程化实战(三):类型检查与发布——mypy 严格模式实战、类型 stub 编写、用 uv build 打包、发布到 PyPI,以及 semantic-release 自动化版本管理。
相关文章:
- Python 项目工程化实战(一):pyproject.toml + uv + Ruff 现代工具链
- Python 类型注解完全指南:从基础标注到 Pydantic 数据验证
- FastAPI 完全指南:从零构建高性能异步 API 到 Docker 生产部署
- Python asyncio 异步编程完全指南
本文所有工具版本信息以 2026 年 7 月为准,pytest 及各插件更新较快,请以官方文档为准。