第22篇|单元测试 pytest
本篇定位:《Python 教程》专栏二「Python 进阶与工程」第 22 篇。承接第 21 篇「能看见问题」之后,把正确性断言工程化:pytest 发现约定、
assert重写、fixture 生命周期、参数化、异常与标记、覆盖率直觉,并挂钩 AI Infra 场景下「测什么 / mock 什么 / 不测什么」。为第 23~25 篇并发与 asyncio、第 26~28 篇 HTTP / FastAPI / Agent 提供可回归底座。读完你将能够:用
uv add --dev pytest搭起最小测试树;说清收集规则与. / F / E;编写 fixture(含yield清理)与@pytest.mark.parametrize;断言异常与用monkeypatch/ 假对象隔离外部依赖;解释为何 deterministic 逻辑进单元测试、模型质量进评测,而不是混成「假绿」。预计用时:阅读 35~45 分钟,动手 40~50 分钟。
第 21 篇给了眼睛与听诊器:日志、断点、profiler。本篇回答下一个问题——改完代码,你怎么知道没把正确性改坏? 只靠「我本地跑了一下看起来对」不够:同事合并会撞你的假设,重构会悄悄改边界,Agent 工具调用循环一旦漏断言,线上就是「偶发胡说」。
一句话定心:测试把「我认为正确」变成「机器可重复验证的合同」。 pytest 是 Python 生态里最常用的那张合同纸——发现约定简单、失败信息可读、fixture 可组合。
建议边读边在第 20 篇练习项目里动手(uv add --dev pytest,uv run pytest)。阅读顺序:为何测 → 发现与 assert → fixture → 参数化与异常 → 隔离依赖 → AI Infra 分层 → 踩坑。下一篇预告 GIL 与多线程(23):正确性钉死后,再谈「共享状态为何难测、为何要隔离」。
和第 11 篇对照:异常是运行时信号;测试是事先约定信号何时该响。和第 18 篇对照:类型与 Pydantic 管形状;pytest 管行为。和第 20 篇对照:锁文件保证同一依赖;测试保证同一行为。和第 21 篇对照:日志解释「炸了什么」;失败断言解释「期望与实际差在哪」。
动手时建议沿用「小推理客户端」意象:纯函数(拼 prompt、解析 JSON)进单元测试;HTTP 调用用假响应;真实打模型放到标记为 slow / integration 的可选套件。做完后打开任意成熟库的 tests/,你会认出同一套形状。
本篇不穷尽 hypothesis 属性测试、不深入 mutation testing、不替代完整测试策略课。主线是:心智模型 → pytest 工作流 → fixture / 参数化 → 隔离与 AI 场景 → 踩坑。和「写完再手工点点」相比,自动化测试的价值是:秒级反馈、可 CI、可回归、倒逼模块边界变清晰。
写测试时保持一个工程直觉:失败消息是给未来值班同事看的合同,不是给现在的自己看的便签。 「奇怪,上次那次边界」如果没有用例钉住,三个月后就会变成传说。社区里那些「只有 E2E、没有单元」的服务,往往是反馈地狱的入口。
也可以把本篇看成专栏二的「可验证面」补课:第 15~19 篇把协议与积木立住,第 20 篇钉环境,第 21 篇给眼睛,本篇把正确性工程化。后面并发、服务、Agent,都假设「你知道如何用断言锁住行为」。
读官方 pytest 文档与 Good Integration Practices 时,按本篇目录对照即可。入门阶段把「会发现、会 fixture、会参数化、会隔离外部」做扎实,比背完整插件生态更重要。
动手时再记一条:测试的第一原则是快且稳定—— fragile 的「偶绿偶红」比没有测试更糟,因为它摧毁信任。排障时一条带清晰 diff 的失败断言,往往比一百行无上下文的 DEBUG 日志更值钱。
和「上线前人工点一遍」相比,本篇推荐的默认策略是:纯逻辑默认有测;I/O 边界用替身;贵且慢的真实依赖单独标记。 这个习惯在重构、夜班告警、开源贡献时都会回报你。
若你刚从第 21 篇「可观测」过来,本篇前两节会换一种爽感:同样是工程化,但挂钩点从「看得见」换成「验得住」。把「我觉得 add 对」改成 assert add(1, 2) == 3,把「手动试三种输入」改成 parametrize——两条路径走完,测试直觉就长出来了。
和「在聊天里口头描述预期行为」相比,自动化用例的价值是:机器可读、可重复、可 diff——本地、CI、GPU 服务器上同一套断言。排障时你面对的是期望与实际,而不是「我记得大概返回过」。
动手前再立一条纪律:红—绿—重构。先写一条失败的测试(红),再写最小实现让它通过(绿),最后在绿网保护下整理结构。很多人倒过来「先写一堆实现再补测」,补测时容易不自觉降低断言强度——测试变成了实现的回声,而不是合同。pytest 让红绿循环足够短,值得养成肌肉记忆。
和「覆盖率数字好看」相比,更值钱的是失败时 30 秒内定位。一条带 ids 的参数化失败、一段 raises 的 match、一次清晰的左右 diff,往往比「全仓 95% 覆盖但断言只检查 is not None」更能保护团队。把这个品味带进 code review,比强制门禁数字温和且有效。
一、为什么测:从「我觉得对」到可重复合同
1.1 一张图看清测试金字塔

图1 测试金字塔:单元 · 集成 · 端到端
# calc.py
def add(a: int, b: int) -> int:
return a + b
# tests/test_calc.py
from calc import add
def test_add_positive():
assert add(1, 2) == 3
def test_add_zero():
assert add(0, 0) == 0
uv add --dev pytest
uv run pytest -q
.. [100%]
2 passed in 0.01s
要点:
- 单元测试钉行为——输入输出合同,不依赖外网与 GPU。
- 集成验证协作——模块之间真连时的契约。
- E2E 验证用户路径——少而精,不当唯一门禁。
1.2 和第 11、18、21 篇的边界
第 11 篇已讲异常;本篇用 pytest.raises 断言「该炸时炸」。第 18 篇用类型与 Pydantic 描述形状;本篇用 assert 描述行为。第 21 篇让失败可观测;本篇让回归可自动发现——可观测 ≠ 测过。
AI Infra 视角:推理客户端里「拼消息、解析 tool call、重试退避」都是确定性逻辑,必须有单元测试;「模型是否答得对」是评测与监控问题,硬塞进
assert output == "标准答案"只会假绿或脆红。vLLM / Agent 框架的 CI 同样区分 unit / integration / eval。
1.3 渐进采用
老项目零测试?可以:先给最稳定的纯函数加两条断言;再给易回归的边界加参数化;外部 HTTP 用假响应。不必一天引入完整覆盖率门禁,但新代码不要再增加无测试的关键路径。
二、pytest 发现、assert 重写与报告
2.1 收集约定

图2 pytest 发现与执行流水线
默认约定(可用配置改,但建议先遵守):
uv run pytest # 从当前目录向下收集
uv run pytest tests/test_calc.py
uv run pytest -k add # 名字匹配
uv run pytest -q # 安静
uv run pytest -vv # 更啰嗦的失败信息
0 collected 时先查:文件名、函数前缀、是否跑错目录、包能否 import。
2.2 assert 重写:失败信息为何可读
unittest 常写 self.assertEqual(a, b);pytest 直接用 assert,并在收集时改写字节码,失败时打印左右值与中间表达式。
def test_list_diff():
got = [1, 2, 3]
expect = [1, 2, 4]
assert got == expect
失败时你会看到清晰的 diff(左右对比),这是选 pytest 的重要理由之一:少样板、多信息。
2.3 结果符号心智
F 与 E 不同:前者是「合同写错了或实现错了」;后者常是 fixture / import / 环境问题——先修 E 再谈断言。
2.4 最小工程布局
project/
src/mypkg/
__init__.py
calc.py
tests/
test_calc.py
pyproject.toml
pyproject.toml 可配置:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
与第 20 篇一致:依赖进 uv,测试命令用 uv run pytest,避免「我机器能跑、CI 找不到包」。
2.5 和 unittest 怎么选
标准库 unittest 能用,但样板多(类、self.assert*)。新项目优先 pytest:断言自然、插件生态强、fixture 比 setUp/tearDown 好组合。维护存量 unittest 时,pytest 仍能收集 TestCase——迁移可以渐进,不必一夜推翻。
若团队已有 unittest 习惯,本篇仍建议至少在新模块用 pytest 风格写「函数式测试」:你会立刻感到失败信息与参数化的红利。两者可以共存于同一 CI 命令:uv run pytest。
三、Fixture:可组合的准备与清理
3.1 参数注入

图3 Fixture:准备 · 注入 · 清理(yield)
测试函数的参数名若与 fixture 同名,pytest 自动注入——无需手动调用。
import pytest
@pytest.fixture
def sample_nums():
return [1, 2, 3]
def test_sum(sample_nums):
assert sum(sample_nums) == 6
3.2 yield:与 with 同构的清理
和第 17 篇对照:yield 前是 setup,后是 teardown。
import pytest
from pathlib import Path
@pytest.fixture
def tmp_data(tmp_path: Path):
p = tmp_path / "data.txt"
p.write_text("hello", encoding="utf-8")
yield p
# 此处可做额外清理;tmp_path 本身由 pytest 管理
def test_read(tmp_data: Path):
assert tmp_data.read_text(encoding="utf-8") == "hello"
内置 tmp_path / tmp_path_factory 解决「测文件别污染仓库」;monkeypatch 解决「临时改环境变量 / 属性」。
3.3 scope 与串扰
可变共享状态放在宽 scope 上极易串扰:测试顺序一变就红。原则:默认 function;只读可共享;可变就别共享。
3.4 conftest.py
同目录及以下测试自动可见的 fixture 放 tests/conftest.py,避免每个文件复制粘贴。不要在 conftest 里塞业务逻辑——它是测试装配层。
# tests/conftest.py
import pytest
@pytest.fixture
def api_base():
return "https://example.test/v1"
四、参数化、异常断言与标记
4.1 parametrize:一份逻辑 × 多组输入

@pytest.mark.parametrize装饰器,对add(a, b) == expect进行参数化测试。右侧“展开成多条用例”框列出多组输入及对应结果,均为PASS。下方“推荐场景”框建议边界值、等价类、错误码表等场景使用,失败时ids可定位用例。右下角“反模式”框提示不要把无关场景硬塞进一组,避免巨型笛卡尔积爆炸,失败信息含糊需补ids。此图与文档中介绍pytest参数化的内容相契合。图4 参数化:一份逻辑 × 多组输入
import pytest
from mypkg.calc import add
@pytest.mark.parametrize(
"a,b,expect",
[
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
],
ids=["pos", "zero", "mixed"],
)
def test_add_cases(a, b, expect):
assert add(a, b) == expect
ids 让失败报告可读。不要把无关场景硬塞进一张表;笛卡尔积过大时拆成多组或用自定义生成。
4.2 断言异常
import pytest
def div(a: int, b: int) -> float:
if b == 0:
raise ZeroDivisionError("b must not be 0")
return a / b
def test_div_zero():
with pytest.raises(ZeroDivisionError, match="must not"):
div(1, 0)
和第 11 篇一致:测「该炸」与测「不该炸」同等重要。match 用正则锚定消息关键词,避免绑死整句文案。
4.3 skip / xfail / 自定义 mark
import pytest
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="posix path only")
def test_posix_path():
assert True
@pytest.mark.xfail(reason="已知上游 bug #123")
def test_known_bug():
assert 1 == 2
@pytest.mark.slow
def test_big():
...
运行:uv run pytest -m "not slow"。在 pyproject.toml 注册自定义 mark,避免 PytestUnknownMarkWarning。
4.4 近似比较与集合
浮点用 pytest.approx;集合/字典直接 == 即可获得可读 diff。
def test_float():
assert 0.1 + 0.2 == pytest.approx(0.3)
五、隔离外部依赖:monkeypatch 与假对象
5.1 为何隔离
单元测试若真去打公网 LLM、真扣费、真写生产库——又慢又脆又贵。隔离的目标:被测逻辑在本地确定性运行。
# client.py
def fetch_status(get_json) -> str:
data = get_json("/health")
return data["status"]
# tests/test_client.py
from client import fetch_status
def test_fetch_status_ok():
def fake_get(url: str):
assert url == "/health"
return {"status": "ok"}
assert fetch_status(fake_get) == "ok"
依赖注入(把 get_json 当参数)比在测试里硬补丁私有全局更清晰——和第 12~14 篇「可替换协作对象」同一思路。
5.2 monkeypatch
import os
import pytest
def test_env(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
assert os.environ["APP_ENV"] == "test"
也可 monkeypatch.setattr(module, "name", fake)。用完自动恢复——又是「上下文式清理」。
5.3 覆盖率直觉(不作教条)
uv add --dev pytest-cov
uv run pytest --cov=mypkg --cov-report=term-missing
覆盖率是地图不是目标:100% 也能测错;关键路径 0% 才危险。优先钉:边界、错误分支、解析与重试。
六、AI Infra:测什么、替什么、不测什么
6.1 分层图

图5 AI Infra 视角:测什么、替什么、不测什么
6.2 假推理响应示例
import json
from typing import Any
def parse_tool_call(payload: dict[str, Any]) -> tuple[str, dict]:
name = payload["name"]
args = json.loads(payload["arguments"])
return name, args
def test_parse_tool_call():
name, args = parse_tool_call(
{"name": "search", "arguments": '{"q": "pytest"}'}
)
assert name == "search"
assert args == {"q": "pytest"}
把「模型返回的字符串」当成输入夹具,测你的解析与编排——不必每次烧 token。
6.3 和日志、后续服务篇的分工
- 第 21 篇:失败时如何看见字段与栈。
- 本篇:如何在合并前自动发现行为回归。
- 第 26~28 篇:真实 HTTP / FastAPI / Agent 循环——测试里继续 mock 上游,E2E 另册。
先把单元层用熟,再谈录制回放与契约测试,不会在概念上打架。
读到这里,不妨停一秒做个自我检查:你能不能说出 pytest 默认收集哪些文件与函数?能不能画 fixture 的 setup / yield / teardown?能不能解释为何「模型输出自然语言」不该成为默认 CI 断言?三问里有两问卡壳,就回到对应插图把箭头再走一遍——本篇其余文字都是在给这五张图加注释。
和「等出了线上事故再补测试」相比,现在付出的用例成本极低:几条纯函数断言、一组参数化边界、一个假 HTTP。事故夜里你会感谢今天的自己。也可以把本篇练习项目当成以后 FastAPI / Agent 篇的测试骨架——目录与 mark 先约定好,比事后补齐便宜得多。
再强调一次边界:本篇教你用 pytest 把确定性合同钉死;属性测试、混沌工程、线上评测是下一层,会在服务与 Agent 实战里逐步露面。不要因为「还没上完整质量平台」就拒绝 test_*.py——本地与单机库同样需要回归网。
若你带团队做 code review,可以把本篇收成三条合并门槛:新增纯逻辑是否有测;外部 I/O 是否可注入或已 mock;失败断言信息是否让人不用猜。门槛不在于「必须 90% 覆盖率」,而在于不许只凭感觉合并。
七、踩坑大全
通用
坑 1:文件不叫 test_*.py,函数不叫 test_*。 现象:0 collected。原因:发现约定未满足。解决:改名或显式配置;先 pytest --collect-only。
坑 2:测试里依赖真实网络 / 真实 API Key。 现象:CI 红、本地绿、还费钱。解决:注入假对象 / monkeypatch / 录制回放;真实调用标 slow 并默认排除。
坑 3:fixture 宽 scope 共享可变列表 / 字典。 现象:用例顺序敏感、偶发失败。解决:默认 function;共享只读;或每测拷贝。
坑 4:用 == 比浮点。 现象:0.1+0.2 脆红。解决:pytest.approx。
坑 5:断言写 assert resp 而不查字段。 现象:假绿(真值对象存在但内容错)。解决:断言关键字段与边界。
坑 6:在测试里改全局配置却不恢复。 现象:污染后续用例。解决:monkeypatch 或 fixture yield 清理。
坑 7:把 E2E 当唯一测试。 现象:反馈慢、难定位。解决:金字塔——单测打底,E2E 少而精。
macOS / Linux / Windows
坑 8:路径与行尾在 Windows 上不同。 现象:快照测试失败。解决:pathlib、统一 newline、避免绑死绝对盘符。
坑 9:Linux CI 容器无显示 / 无 GPU 却跑 GUI 或 CUDA 测。 现象:整仓红。解决:mark 分离;GPU 测单独 job。
坑 10:macOS 大小写不敏感导致 import 与 CI(Linux)不一致。 解决:文件名大小写与包名严格一致。
与 AI / 服务叠加
坑 11:assert model_answer == golden。 现象:模型一更新全红或刻意 prompt 讨好测试。解决:测解析与编排;质量走评测集。
坑 12:测试日志级别 DEBUG 刷屏掩盖失败。 现象:报告不可读。解决:测试默认 WARNING;需要时 --log-cli-level。
八、小结
合上文章后,试着用自己的话说明:pytest 如何发现测试、fixture 为何像「带调度的 with」、以及为何 AI 场景要拆开确定性与非确定性。讲得清,才算真的会了。动手至少跑通:两条 assert → 一个 yield fixture → 一组 parametrize → 一次 pytest.raises。
- 合同思维:测试把预期变成可重复断言;金字塔里单元是主战场。
- 发现与报告:约定文件 / 函数名;
./F/E含义不同;assert 重写提供 diff。 - Fixture:参数注入、
yield清理、scope 防串扰、conftest.py共享装配。 - 参数化与标记:多输入少样板;
raises/skip/xfail/-m。 - 隔离:依赖注入与
monkeypatch;覆盖率当地图。 - AI Infra:确定性进 CI;模型质量进评测;假响应测编排。
把故事连起来:第 11 篇异常 → 第 18 篇形状 → 第 20 篇环境 → 第 21 篇可观测 → 本篇可验证。专栏二工程刀具继续成套:下一站是 GIL 与多线程(23)。
若只能带走一句话:能自动验的别靠手点;能确定性验的别靠烧 token。
也可以用「三个不要」自检:不要无发现约定就怪 pytest;不要宽 scope 共享可变状态;不要把非确定性模型输出写进默认门禁。
再留一个自测:给练习项目的纯函数加参数化三条,再给「除零」加 pytest.raises,用 monkeypatch 改一个环境变量并断言——三问都顺,本篇就站稳了。
本篇技能会立刻复利:读开源先找 tests/;PR 里要求逻辑变更带测;重构争议先跑红绿。后续 GIL、多进程、asyncio、FastAPI、Agent,都假设这层已稳。
和专栏一串起来复习也不亏:函数与模块(07~09)是可测单元的边界;异常(11)是负向用例;本篇是专栏二把「可验证」立成一等约束的一课。
也可以把收获压成检查清单:会 uv run pytest;会写 fixture + yield;会 parametrize;会 raises;能说出 mock 与评测的分工。五项过关,即可进入并发篇。
若把本篇压成给同事的三十秒讲解:先画测试金字塔;再强调「发现约定 + fixture」;最后提「AI 别假绿」。三张草图比贴一篇博客更管用。等对方能独立把手工点测迁到 pytest,并解释 F 与 E,就可以一起 code review 测试 PR 了。
动手清单再压缩一版:uv add --dev pytest;写 test_add;加 tmp_path fixture;参数化边界;raises 除零。五步做完,比只「看完文章」扎实一个数量级。
九、课后练习
- (基础) 用
uv add --dev pytest建tests/test_calc.py,为add/mul各写至少两条断言,uv run pytest -q全绿。 - (基础) 故意写错期望值,阅读失败 diff;再用
-vv对比信息量,用自己的话写三句差别。 - (基础) 编写带
yield的 fixture:创建临时文件、供测试读写、yield 后断言清理意图(可配合tmp_path)。 - (理解) 用
@pytest.mark.parametrize覆盖至少 5 组边界(含负数 / 零);为每组设ids。 - (理解) 为「非法输入」写
pytest.raises,并使用match=;再写一条「合法输入不抛」的对称用例。 - (理解) 用
monkeypatch.setenv测试读取配置的函数;确认测试结束环境恢复(可再开一条用例验证)。 - (进阶) 把「HTTP GET 返回 JSON」抽成可注入依赖,单测只喂假函数;说明若改成模块内
requests.get,你要如何setattrmock(任选一条路径实现)。 - (进阶 · AI Infra) 设计「假 tool_call 载荷」表(至少 4 条:正常、缺字段、非法 JSON、未知工具名),用参数化测解析函数;写一段话说明:哪些断言属于 CI 门禁,哪些属于真人评测,为什么。
下一篇预告
下一篇是《第23篇|GIL 与多线程》——在正确性能被 pytest 钉住之后,解决「多线程共享状态为何危险、GIL 到底保护什么、何时该用线程」。你会看到:没有测试的并发代码更可怕;而可测的设计往往倒逼你减少共享可变状态——这与 GIL 篇的心智模型同向。
专栏二后续路线:GIL 与多线程 → 多进程与 futures → asyncio → HTTP / FastAPI → 最小 Agent。带着本篇的断言与隔离纪律,写并发时记得:竞态很难用肉眼复现,更要靠小步测试与清晰边界。
若想提前热身:给当前练习项目加一个带可变全局的小函数,试着在测试里并行调用(哪怕只用简单循环模拟交错),观察为何「偶发失败」令人抓狂。做完后第 23 篇谈 GIL 与锁时你会带着具体体感。
也可以把第 15~22 篇对照:生成器管流、装饰器管横切、上下文管资源、类型管形状、标准库给积木、依赖钉环境、日志给眼睛、本篇给安全带——工程八件套的轮廓已经清晰,下一篇把「共享与并行」说透。
专栏一用十四篇把「对象与协议」立住;专栏二从迭代器(15)走到 pytest(22),下一站是 GIL。下一篇见。
若时间充裕,再多做一件事:打开你最近用过的一个开源 Web / Agent 项目,找到它的 tests/ 与 CI 配置,记三条「它如何 mark 慢测、如何 mock 外部 API」。这种阅读比空背命令更能把纪律刻进肌肉记忆。
进入专栏二至此,可验证面与可观测面开始交汇:会看见问题,也要会在合并前自动发现回归。下一篇开始把线程与 GIL 配齐。若某一节读着抽象,回到对应插图把箭头走一遍,再手打 pytest / fixture / parametrize——多数卡住的地方,其实是「单元、集成、评测」被混成同一把锤子。
写测试或读失败报告时再记一条:错误往往来自合同与实现错配(测了不该测的非确定性、没测该测的边界)。和「栈很长但根因很浅」相比,先选对测试层级能省很多时间。
也可以把本篇看成专栏二的「回归面」补课:前面把协议、积木、环境与眼睛收齐,本篇把断言收齐。后面并发篇教你如何在更多任务下仍保持正确;FastAPI 与 Agent 篇则把测试打进真实请求与工具循环。
若阅读时觉得「要不要上完整测试平台」总搅在一起,回到图 1:先单元金字塔底层;平台与 E2E 是上一层。日常编程两者都要懂一点,但职责不同——混谈是多数初学者卡住的根源。
收官前对照练习:同一段会算错的函数,分别用「手工 print 看结果」与「pytest 断言 + 参数化」各验一次。你会清楚感受到:测试不是纸面游戏,而是把「我这边看过一眼」抬到与团队契约同一套操作面的关键一步——也是专栏二工程化与服务 / Agent 对接的入场券之一。
写完练习后,不妨在自己的仓库里开一个小 PR:只引入 tests/ 与 uv run pytest,不改业务语义。这种「小步加网」比空读文档更能把工作流刻进肌肉记忆,也更适合团队推广。
若你带新人,可以把本篇插图打印成一页速查:左列场景(纯函数 / 文件 / HTTP / LLM),右列策略(直断言 / tmp_path / mock / 评测)。新人写服务时对着贴,两周后就会自然说出「先单测」与「别假绿」。工具文化是带出来的,不是靠一次分享会喊出来的。
最后把专栏二的位置再钉一次:协议篇(15~18)解决「怎么正确接线」;标准库与依赖篇(19~20)解决「积木与环境」;可观测篇(21)解决「如何被看见」;本篇解决「如何被自动验证」。四者齐备,脚本才既写得快,又改得敢——下一篇再给它配上线程与 GIL 的实话。
