Python 测试:pytest、替身与分层验证

# Python 测试:pytest、替身与分层验证

本篇目标

建立能支撑真实后端项目的测试结构,理解 Fixture、参数化、Fake、Mock 和集成测试各自解决什么问题,并避免普通测试真实调用模型。

设计测试分层使用 Fixture替换外部依赖测试异步接口

# 先记住一句话

测试不是把实现再运行一遍,而是在可控输入和依赖下验证稳定契约;纯业务用 Fake 做快速单元测试,HTTP 与数据库边界做集成测试,真实模型质量另用评测集验证。

# 四层验证解决不同问题

层次 主要验证 是否真实访问外部系统
单元测试 单个函数或 Service 的业务规则 否,使用 Fake 或 Stub
组件测试 路由、序列化、Repository 等一个组件边界 通常使用本地测试数据库
集成测试 应用与数据库、缓存或消息系统能否协作 使用受控的真实依赖
端到端与 AI 评测 部署链路和模型答案质量 少量、受预算控制地调用真实系统

测试金字塔不是规定比例,而是在提醒:越靠近真实外部系统,速度越慢、波动越大、定位越困难,因此数量通常越少。

# Fixture 负责可靠的测试上下文

Fixture(测试夹具)是为测试准备并清理运行环境的机制,不是独立的测试库。使用 @pytest.fixture 注册函数后,测试只需声明同名参数,pytest 就会自动调用并注入结果;使用 yield 时,前面负责准备资源,后面负责清理资源。

# 文件位置:tests/conftest.py
from collections.abc import Iterator

import pytest
from fastapi.testclient import TestClient

from note_api.main import app

# @pytest.fixture 把函数注册为测试夹具,测试可通过同名参数取得它的结果。
@pytest.fixture
def client() -> Iterator[TestClient]:
    # with 会执行 FastAPI lifespan,测试结束后也会触发关闭逻辑。
    with TestClient(app) as test_client:
        # yield 把客户端交给测试;测试结束后会回到这里退出 with 并清理资源。
        yield test_client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Fixture 可以依赖其他 Fixture,并在 yield 后做清理。默认函数级作用域隔离性最好;只有创建资源确实昂贵且状态可以安全重置时,才扩大到 module 或 session 作用域。

# 参数化覆盖同一规则的边界

# 文件位置:tests/test_validation.py
import pytest

from note_api.validation import normalize_title

# parametrize 会用下方每组 raw 和 expected 数据分别执行一次同一个测试。
@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        (" Python ", "Python"),  # 去除首尾空白。
        ("Go\nService", "Go Service"),  # 把连续空白统一为单个空格。
        ("", ""),  # 空输入保持为空,由更外层决定是否允许。
    ],
)
def test_normalize_title(raw: str, expected: str) -> None:
    assert normalize_title(raw) == expected
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

参数化适合 “准备方式和断言相同,只有输入输出不同” 的案例。如果不同案例需要完全不同的行为和断言,应拆成具名测试,避免一张难读的大表。

# Fake、Stub 与 Mock 怎样选

Fake、Stub、Mock 和 Spy 不是 Python 特有的测试库,而是跨语言通用的测试替身(Test Double)类型。它们可以手动编写,也可以由测试框架创建;不同语言社区的叫法可能不完全一致,很多项目也会把各种测试替身统称为 Mock。

  • Fake:能工作的轻量实现,例如内存 Repository;
  • Stub:为特定调用返回预设值,重点是控制输入;
  • Mock:记录调用,用于验证重要交互是否发生;
  • Spy:包裹真实实现并记录调用。

优先通过接口注入 Fake。只有当 “是否以特定参数调用依赖” 本身就是契约时,才验证 Mock 调用;不要把内部每一步都 Mock,否则重构实现会导致大量无意义测试失败。

# 文件位置:tests/test_summary_service.py
from dataclasses import dataclass, field

import pytest

from note_api.summary import SummaryService

# dataclass 自动生成测试替身的初始化方法;field 为每个实例创建独立列表。
@dataclass
class FakeModelClient:
    response: str
    prompts: list[str] = field(default_factory=list)

    async def summarize(self, prompt: str) -> str:
        self.prompts.append(prompt)  # 记录调用,便于验证关键交互。
        return self.response  # 不访问真实模型,测试结果保持确定。

# 该标记让 pytest 在 asyncio 事件循环中运行 async def 测试。
@pytest.mark.asyncio
async def test_summary_uses_note_content() -> None:
    model = FakeModelClient(response="简短摘要")
    service = SummaryService(model)

    result = await service.summarize("正文")

    assert result == "简短摘要"
    assert model.prompts == ["请总结以下内容:\n正文"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

这个异步测试需要在测试依赖中安装 pytest-asyncio,并在 pyproject.toml 中配置:

# 文件位置:pyproject.toml
[project.optional-dependencies]
test = [
  "pytest>=8",          # 测试运行器与 Fixture 系统。
  "pytest-asyncio>=0.24", # 运行标记为 asyncio 的异步测试。
  "httpx>=0.27",       # 通过 ASGITransport 测试异步 HTTP 接口。
]

[tool.pytest.ini_options]
asyncio_mode = "auto"  # 异步测试默认使用 asyncio 事件循环。
testpaths = ["tests"]   # 只从 tests 目录收集项目测试。
1
2
3
4
5
6
7
8
9
10
11

# 测试异步 FastAPI 接口

# 文件位置:tests/test_notes_api.py
import pytest
from httpx import ASGITransport, AsyncClient

from note_api.auth import Principal, require_principal
from note_api.main import app

def fake_principal() -> Principal:
    # 测试覆盖认证依赖,不需要伪造真实密钥或令牌。
    return Principal(subject="user-1")

@pytest.mark.asyncio
async def test_create_note_returns_201() -> None:
    # dependency_overrides 用测试身份替换真实认证依赖。
    app.dependency_overrides[require_principal] = fake_principal
    # ASGITransport 直接调用应用,不需要真正监听网络端口。
    transport = ASGITransport(app=app)

    try:
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            response = await client.post(
                "/notes",
                json={"title": "Python", "content": "运行时笔记"},
            )
    finally:
        # 全局 app 会被其他测试复用,覆盖项必须清理。
        app.dependency_overrides.clear()

    assert response.status_code == 201
    assert response.json()["title"] == "Python"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

更完整的项目可以用 Fixture 统一设置和清理依赖覆盖。若测试依赖 lifespan 中创建的资源,应确保测试客户端实际进入应用生命周期,或显式调用 lifespan 管理器。

# 测试数据库事务

数据库测试应使用与生产尽量相同的数据库类型,因为 SQLite 与 PostgreSQL 在约束、并发和 SQL 方言上存在差异。常见方式是为测试启动临时数据库,每个测试在独立事务中运行并回滚。

测试套件开始
└─ 启动或连接测试数据库
   └─ 应用迁移
      └─ 每个测试
         ├─ 开启隔离事务
         ├─ 执行 Repository / API
         └─ 回滚,恢复干净状态
1
2
3
4
5
6
7

不要让测试连接开发或生产数据库,也不要只靠手工删除几张表 “清理” 。CI 中的数据库地址应有独立凭据和明确命名保护。

# monkeypatch 应补在使用位置

# 文件位置:tests/test_settings.py
from note_api.settings import load_settings

def test_load_settings_reads_environment(monkeypatch) -> None:
    # pytest 会在测试结束后自动恢复环境变量。
    monkeypatch.setenv("APP_ENV", "test")

    settings = load_settings()

    assert settings.environment == "test"
1
2
3
4
5
6
7
8
9
10

如果模块写了 from time import time,需要替换的是该模块正在使用的 module.time,而不是原始 time.time。更长期的方案是把时钟、随机数和外部客户端作为显式依赖注入,减少全局 patch。

# AI 项目要把接口测试和质量评测分开

普通自动化测试适合断言:

  • 是否构造了正确请求;
  • 超时和错误是否被正确映射;
  • 是否只允许调用批准的工具;
  • 引用和结构化输出是否满足 Schema;
  • Fake 模型返回特定结果时,系统状态是否正确变化。

真实模型评测才适合判断事实正确率、引用准确率、工具选择和安全表现。不能在普通单元测试中断言生成文本必须逐字相等,也不能把一次人工观察当成稳定回归证据。

# 高频面试题与回答

1. Fixture 和普通辅助函数有什么区别?参考答案

Fixture 由 pytest 根据测试参数自动解析,可以依赖其他 Fixture、控制作用域并保证 teardown,适合建立可靠测试上下文。纯计算或不需要生命周期管理的重复逻辑,普通辅助函数通常更简单。

2. Mock 越多测试越隔离,为什么不一定更好?参考答案

过度 Mock 会把测试绑定到内部调用步骤,重构实现就失败,却未必能证明最终行为正确。优先从业务边界注入小接口并使用 Fake,只对真正重要的外部交互验证调用参数,再用少量集成测试覆盖组件协作。

3. 为什么真实模型调用不放在普通单元测试中?参考答案

真实模型存在成本、延迟和非确定性,会让普通测试变慢且不稳定。单元测试用 Fake 验证系统契约,另用固定数据集和容差指标运行模型评测,这样能区分代码回归与模型质量波动。

4. 单元测试和集成测试怎样划分?参考答案

单元测试在进程内快速验证一小块业务规则,并用 Fake 隔离昂贵外部依赖;集成测试验证数据库、HTTP、序列化或组件组合是否真的兼容。项目应以大量快速单元测试提供反馈,再用较少集成测试覆盖最容易在边界处出错的真实协作。

# 接下来学什么

最后进入 Python 完整项目阅读实战,把入口、依赖、路由、Service、Repository、数据库和测试串成一条调用链。

# 参考资料

上次更新时间: 2026年09月10日 00:03:52