test_ 函数 · assert 改写 · fixture · conftest.py · 作用域 · parametrize · dependency_overrides · MockTransport · monkeypatch · coverage
pytest 写测试:不起日志代理,也能把值班助手测透
pytest 只要求测试是名字以 test_ 开头的普通函数,断言就用普通的 assert:它在导入测试文件时改写这些 assert,失败时把两边的值摊开给你看。精华是 fixture:测试函数的参数名就是它要的东西,pytest 按名字找到同名的 fixture,现场造好、用完收拾;作用域决定造几次、哪些测试共用一份。再配上 FastAPI 的 dependency_overrides 和 httpx 的 MockTransport,不起任何外部服务就能把接口测透。不懂这一点的典型误用:几个测试共用一份可变数据,单独跑都过、一起跑就挂;或者替身换上了却忘了换回来。
全景:一个 FastAPI 请求的一生 · 第 10 站地图:站 01 · pytest 写测试以 pytest 9.2.0、FastAPI 0.162.2 为例
①为什么学:同一条路,在测试里再走一遍,但把网络换成假的
在全景第 10 站,这一步要解决的是:不可能每次跑测试都有 5 台机器、一个日志代理在线,可要测的偏偏是“order-04 卡住不回”“日志为空”“参数越界”这些平时碰不到的情况。办法是让请求照样走第 2~9 站的路由、校验和序列化,只把最外面拉日志那一下换成假的,假数据想造什么样就造什么样。
②老办法:JUnit 你很熟,Python 自带的 unittest 长得和它几乎一样
@WebMvcTest(GcController.class)
class GcControllerTest {
@Autowired MockMvc mvc;
@MockBean LogAgentClient agent;
@Test void unknownServiceIs404() throws Exception {
mvc.perform(get("/gc/summary")
.param("service", "pay"))
.andExpect(status().isNotFound());
}
}
class TestParse(unittest.TestCase):
def setUp(self):
self.log = fake_log(12.0, 244.05)
def test_max(self):
h = parse("order-01", self.log)
self.assertEqual(h.max_ms, 244.05)
self.assertIsNone(h.error)
assertXxx;共享准备只有 setUp 这一种粒度。def test_max(agent_logs):
h = parse("order-01", agent_logs["order-01"])
assert h.max_ms == 244.05
assert h.error is None
- 更老的办法是写个脚本
print一下、肉眼看对不对:没法进 CI,改代码后也没人会重跑。 - unittest 能用,但样板多:类、继承、
self.assertEqual,准备数据只能放在 setUp 里,粒度太粗。 - 缺的那一块就是这页的精华:按参数名注入、可组合、可指定作用域的 fixture,加上会“自己解释失败原因”的 assert。
③选谁:Python 测试的几种工具
| 工具 | 是什么 | 什么时候用 |
|---|---|---|
| unittest | 标准库自带,xUnit 风格 | 不能装第三方包的环境;老项目(pytest 也能直接跑它的用例) |
| pytest | 事实上的标准,插件生态最大 | 默认就选它 |
| doctest | 把文档字符串里的示例当测试跑 | 保证文档里的例子没过时 |
| Hypothesis | 基于性质的测试,自动生成大量输入找反例 | 解析器、序列化这类“输入千奇百怪”的代码,配合 pytest 用 |
本页学 pytest。测接口时还有一个选择:真起 uvicorn 再用 HTTP 打,还是用 TestClient 在进程里直接调。前者更像线上但慢、要管端口和进程;后者快、稳,覆盖第 2~9 站的全部逻辑。默认用 TestClient,真起服务的测试留给少量冒烟测试。
④原理:按名字收集,改写 assert,按参数名注入 fixture
- 生成器和
yield:函数跑到yield停住,之后再从那里接着跑。fixture 用它把“准备”和“收拾”写在一个函数里。见 asyncio 页的“先知道这些”。 - 名字可以重新绑定:monkeypatch 改的是某个模块字典里的一个名字,见 闭包和装饰器 ④-4。
- FastAPI 的依赖:每个请求现场调用依赖函数,见 FastAPI ④-3。
1收集:按名字找测试,找不到不会报错 ≈ JUnit 扫描 @Test
testpaths 配了就只看那里(⑤ 里配的是 tests),否则从当前目录往下找。
文件名是 test_*.py 或 *_test.py。
模块级的 test_ 开头函数;Test 开头、没有 __init__ 的类里 test_ 开头的方法。
沿途目录里的 conftest.py 自动导入,里面的 fixture 对同目录及以下的测试可见,不用 import。
和 JUnit 的差别:JUnit 看注解,pytest 看名字。名字不对不会报错,只是“没有收集到”。一个测试都没收集到时,退出码是 5,输出只有一行 no tests ran 实测。
2assert 改写:导入测试文件时,把 assert 换成会解释自己的代码
$ pytest test_a.py
> assert got == {"host": "order-01", "pauses": 60, "max_ms": 256.2}
E AssertionError: assert {'host': 'ord...x_ms': 244.05} == {'host': 'ord...ax_ms': 256.2}
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'max_ms': 244.05} != {'max_ms': 256.2}
- 1pytest 装了一个导入钩子:导入
test_*.py和conftest.py时,先把源码解析成语法树,把每个assert改写成“先算出每个子表达式、失败时把它们拼成说明”的代码,再编译执行。 - 2所以不用记
assertEqual、assertIn:字典比较会指出哪个键不同,列表比较会指出第几个元素不同。上面是本机实测输出 实测。 - 3只改写测试文件和 conftest。被测代码里的 assert 不会被改写,失败时只有一行 AssertionError。
3fixture:参数名就是依赖,yield 前准备、yield 后收拾 ≈ @BeforeEach / @AfterEach,但按需、可组合
scope)决定造了之后给谁共用:默认 function 每个测试一份;module 一个文件一份;session 整次运行一份。共用的东西如果被测试改了,后面的测试就会看到改过的版本。实验台:fixture 作用域,6 种组合全是本机 pytest 实测
fixture logs 返回一个字典 {"order-02": "Pause ... 80.400ms"}。test_one.py 里有 test_1、test_2,test_two.py 里有 test_3;test_2 和 test_3 都断言这条日志不为空。
先猜:三个测试里会挂几个?
4换掉外部依赖的三层办法:越靠近边界越好 ≈ @MockBean、WireMock、反射改字段
app.dependency_overrides[get_client] = 假的。FastAPI 调依赖时先查这张表。只对 FastAPI 的 Depends 有效,用完要 clear()。≈ @MockBean。
真的 httpx.AsyncClient,只是传输层换成一个函数:请求不出进程,由 handler 决定返回什么,或者抛什么异常。≈ WireMock,但在进程里。
monkeypatch.setattr(模块, "名字", 假的),测试结束自动改回。什么都能换,但要换对模块。≈ 反射改字段。
怎么选:⑤ 里两层一起用:overrides 把客户端换掉,MockTransport 让这个客户端返回假日志。这样 fetch、parse、超时和异常处理都是真代码在跑,被替换的只有“网络”这一层。monkeypatch 留给环境变量、时间这类没有依赖注入入口的东西。
5为什么这样设计
pytest:普通函数 + 按名字注入选了它
- 测试就是函数,要什么写在参数里。准备和收拾按需组合,不用把所有准备都塞进一个 setUp。
- assert 改写让断言既短又能解释失败原因。
- 代价:规则隐式。fixture 从 conftest 里“凭空”出现,名字拼错就报
fixture 'clinet' not found;作用域选错就互相污染。
xUnit(JUnit、unittest):类 + 生命周期注解没选
- 结构显式,读代码就知道 setUp 在哪。
- 准备工作和测试类绑死,不同测试要的东西不同时,只能拆类或者在 setUp 里全准备一遍。
- 断言要记一整套方法名,失败信息靠方法自己写。
决定胜负的约束:Python 的函数参数在运行时可以读出来(上一页 FastAPI 也靠这一点),所以“参数名即依赖”几乎零成本;而 Python 本来就有 assert 语句,只要改写一下就能给出好的失败信息。两件事都不需要新语法,学起来就是写普通函数。
最少的语法:JUnit 怎么写,pytest 怎么写 细节放到 ⑥
| JUnit 5 / Spring | pytest | 说明 |
|---|---|---|
@Test void x() | def test_x(): | 文件名也要 test_ 开头 |
assertEquals(a, b) | assert a == b | 失败时自动展开 |
assertEquals(a, b, 0.01) | assert a == pytest.approx(b, abs=0.01) | 浮点比较 |
assertThrows(X.class, ...) | with pytest.raises(X, match="..."): | |
@BeforeEach / @AfterEach | @pytest.fixture + yield | 按参数名注入 |
@BeforeAll | @pytest.fixture(scope="module") | 还有 session |
@ParameterizedTest @CsvSource | @pytest.mark.parametrize("a, b", [...]) | 每组参数算一个测试 |
@Disabled | @pytest.mark.skip(reason=...) | 还有 skipif、xfail |
@MockBean | app.dependency_overrides[dep] = fake | FastAPI |
MockMvc | TestClient(app) | 要写 with 才跑 lifespan |
@TempDir Path dir | def test_x(tmp_path): | 内置 fixture |
mvn test -Dtest=X#y | pytest tests/test_api.py::test_y | 还有 -k、-x、--lf |
⑤跑起来:给值班助手写 10 个测试,第一次就抓到一个真 bug
接着 FastAPI 页的 main.py 写。测试只依赖开发环境,用 --dev 装:
$ uv add --dev pytest pytest-cov
# 本机装到 pytest 9.2.0、pytest-cov 7.1.0;写进 pyproject 的 [dependency-groups] dev,不进生产依赖
[tool.pytest.ini_options]
pythonpath = ["."] # 让 tests/ 里能 import main
testpaths = ["tests"] # 只在 tests/ 下找测试
import httpx
import pytest
from fastapi.testclient import TestClient
import main
PAUSE_LINE = "[2026-11-08T20:05:00.000+0800][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(1024M) {ms}ms"
def fake_log(*ms: float) -> str:
return "\n".join(PAUSE_LINE.format(ms=f"{m:.3f}") for m in ms)
@pytest.fixture
def agent_logs():
"""每个测试一份新的“日志代理数据”,测试里可以随便改。"""
return {"order-01": fake_log(12.0, 244.05), "order-02": fake_log(80.4),
"order-03": "", "order-04": None, "order-05": fake_log(41.0)} # None = 卡住不回
@pytest.fixture
def client(agent_logs):
async def handler(request: httpx.Request) -> httpx.Response:
host = request.url.path.split("/")[1]
if agent_logs[host] is None:
raise httpx.ReadTimeout("agent hung", request=request)
return httpx.Response(200, text=agent_logs[host])
fake = httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="http://agent")
main.app.dependency_overrides[main.get_client] = lambda: fake
with TestClient(main.app) as c: # with:会跑 lifespan
yield c
main.app.dependency_overrides.clear() # 收尾:别让替身漏到别的测试
import pytest
from main import parse
from conftest import fake_log
def test_parse_counts_and_max():
h = parse("order-01", fake_log(12.0, 244.05, 33.1))
assert h.pauses == 3
assert h.max_ms == 244.05
assert h.avg_ms == pytest.approx(96.4, abs=0.05)
def test_parse_empty_log_is_not_an_error():
h = parse("order-04", "")
assert h.error == "no pauses"
assert h.max_ms is None
@pytest.mark.parametrize("line, expected", [
("... Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(1024M) 23.456ms", 1),
("... Pause Full (System.gc()) 900M->300M(1024M) 812.000ms", 1),
("... Using 8 workers of 8 for evacuation", 0),
("... Pause Young 23.456 ms", 0), # 单位前多了空格,正则不认
])
def test_which_lines_count(line, expected):
assert parse("h", line).pauses == expected
def test_summary_marks_hung_host(client):
r = client.get("/gc/summary", params={"service": "order", "minutes": 10})
assert r.status_code == 200
hosts = {h["host"]: h for h in r.json()["hosts"]}
assert hosts["order-01"]["max_ms"] == 244.05
assert hosts["order-03"]["error"] == "no pauses"
assert hosts["order-04"]["error"] is not None
def test_unknown_service_is_404(client):
r = client.get("/gc/summary", params={"service": "pay"})
assert r.status_code == 404
assert r.json() == {"detail": "unknown service: pay"}
def test_minutes_out_of_range_is_422(client):
r = client.get("/gc/summary", params={"service": "order", "minutes": 0})
assert r.status_code == 422
assert r.json()["detail"][0]["loc"] == ["query", "minutes"]
def test_agent_change_only_affects_this_test(client, agent_logs):
agent_logs["order-02"] = ""
r = client.get("/gc/order-02")
assert r.json()["error"] == "no pauses"
$ uv run pytest -q
tests/test_api.py:2:
E httpx.ReadTimeout: agent hung
tests/conftest.py:23: ReadTimeout
FAILED tests/test_api.py::test_summary_marks_hung_host - httpx.ReadTimeout: a...
1 failed, 9 passed in 0.35s
httpx.ReadTimeout,模拟的是“httpx 自己的超时先到了”。fetch 只接住了 asyncio.timeout 抛的 TimeoutError,而 httpx.ReadTimeout 不是它的子类,异常冒到 gather,整个接口 500。全景页预测题 2 推出过这件事,现在被测试实打实抓到了。 except TimeoutError:
return HostGc(host=host, error="timeout 1s")
except httpx.HTTPError as e: # httpx 自己的超时、连不上:也记成这一台出错
return HostGc(host=host, error=type(e).__name__)
$ uv run pytest -v tests/test_api.py::test_summary_marks_hung_host PASSED [ 10%] tests/test_api.py::test_unknown_service_is_404 PASSED [ 20%] tests/test_api.py::test_minutes_out_of_range_is_422 PASSED [ 30%] tests/test_api.py::test_agent_change_only_affects_this_test PASSED [ 40%] tests/test_parse.py::test_parse_counts_and_max PASSED [ 50%] tests/test_parse.py::test_parse_empty_log_is_not_an_error PASSED [ 60%] tests/test_parse.py::test_which_lines_count[... Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(1024M) 23.456ms-1] PASSED [ 70%] tests/test_parse.py::test_which_lines_count[... Pause Full (System.gc()) 900M->300M(1024M) 812.000ms-1] PASSED [ 80%] tests/test_parse.py::test_which_lines_count[... Using 8 workers of 8 for evacuation-0] PASSED [ 90%] tests/test_parse.py::test_which_lines_count[... Pause Young 23.456 ms-0] PASSED [100%] ============================== 10 passed in 0.11s ============================== $ uv run pytest -q --cov=main --cov-report=term-missing Name Stmts Miss Cover Missing main.py 67 3 96% 36, 57, 85
- 看参数化的 4 组各算一个测试,名字里带着参数,挂了一眼知道是哪一组。第 4 组故意写了个“单位前多一个空格”的行,确认正则不会误认。
- 看
test_agent_change_only_affects_this_test把 order-02 的日志改成空的。因为agent_logs是默认的 function 作用域,别的测试拿到的还是原来的数据。 - 看覆盖率 96%,没覆盖的三行各有原因:第 36 行是真的
get_client,被替身换掉了所以从没执行;第 57 行是asyncio.timeout那个分支,替身是立刻抛异常,没真等满 1 秒;第 85 行是/healthz,没写测试。覆盖率只告诉你哪些行没跑过,不告诉你测得对不对。
实测 本机 uv 0.13.7、CPython 3.13.18、pytest 9.2.0、pytest-cov 7.1.0、FastAPI 0.162.2、httpx 0.28.1,输出逐字复制(失败输出只保留了关键几行,省略了 TestClient 的一条弃用警告)。10 个测试 0.11 s。
⑥细节·规范·优化
第一圈 8 个坑,7 个在本机复现过 实测,第 8 个在闭包和装饰器那页复现过。
注意事项:不知道就会出事
test_,一个都没跑← ④-1 按名字收集
- 现象
check_parse.py、def parse_test()、class GcTests都不会被收集。输出no tests ran,退出码 5。- 根源
- pytest 只按名字规则找测试,名字不对就当普通代码。
- 做法
- 统一
tests/test_*.py、def test_*、class Test*;CI 里把退出码 5 也当失败。
TestClient 没写 with,lifespan 没跑← ④-3 准备和收拾
- 现象
c = TestClient(main.app)直接请求,报AttributeError: 'State' object has no attribute 'client'。- 根源
- lifespan 是在
with TestClient(app)进入时跑的。不写 with,共享客户端根本没建。 - 做法
- 放进 fixture:
with TestClient(app) as c: yield c。
- 现象
test_a把get_hosts换成lambda: []没清;test_b期望 404,拿到 200。单独跑test_b是通过的。- 根源
dependency_overrides挂在app上,app是整个进程共用的模块级对象。- 做法
- 只在 fixture 里设,
yield之后clear();或者用monkeypatch.setitem(app.dependency_overrides, dep, fake),自动还原。
- 现象
scope="module"的字典被test_1改了,同文件的test_2挂;单独跑test_2通过。换成session,另一个文件的test_3也挂。见实验台。- 根源
- 作用域越大,共用这一个对象的测试越多。
- 做法
- 可变数据用默认的 function 作用域;大作用域只放只读或昂贵的东西(数据库连接、加载好的模型),并且返回只读视图或副本。
== 比较设计如此
- 现象
assert 0.1 + 0.2 == 0.3失败。平均停顿这种算出来的数尤其容易中招。- 根源
- 二进制浮点数表示不了 0.1,和 Java 的 double 一样。
- 做法
assert avg == pytest.approx(96.4, abs=0.05)。
assert (条件, "说明") 永远通过← ④-2 assert 语法
- 现象
assert (1 == 2, "never fails")通过了,只留下一条PytestAssertRewriteWarning: assertion is always true。- 根源
- 括号把两样东西包成一个非空元组,非空元组永远为真。
- 做法
- 写成
assert 1 == 2, "说明",不加括号。
async def test_...,跑不起来设计如此
- 现象
- 不装插件时报
async def functions are not natively supported,测试失败。 - 根源
- pytest 本身不带事件循环,协程函数调用了只会得到协程对象。
- 做法
- FastAPI 已经带了 anyio,它自带 pytest 插件:加
@pytest.mark.anyio就能跑 实测;也可以装 pytest-asyncio。测接口用同步的 TestClient 就够了。
- 现象
- 改了
main.fetch,用from main import fetch的模块还在调真的。 - 根源
- 每个模块各查各的字典,见闭包和装饰器页坑 7(那页实测)。
- 做法
- 能用 dependency_overrides 或 MockTransport 就别 patch;要 patch 就 patch 调用方查名字的那个模块。
使用规范:团队里怎么写
名字写清楚验证什么:test_unknown_service_is_404。
挂了要读半天才知道哪儿坏了。
优先换网络(MockTransport),其次换依赖,最后才 monkeypatch。
替身换得太里面,真代码其实没测到。
按目录分层:tests/conftest.py 放全局的,tests/api/conftest.py 放接口专用的。
每个文件复制一份准备代码。
任何一个测试单独跑都要能过;CI 里可以加 pytest-randomly 打乱顺序。
坑 3、坑 4 这类“时过时不过”。
需要真服务的放进单独的目录或标记(@pytest.mark.integration),默认不跑。
测试慢、受别人的服务影响而随机失败。
看“没覆盖的是哪几行、为什么”,像 ⑤ 里那样逐行解释。
为了数字写一堆不验证任何东西的测试。
优化和深处
常用命令
-x | 第一个失败就停 |
--lf | 只重跑上次失败的 |
-k "summary and not 404" | 按名字挑测试 |
-vv | 完整展开 diff(④-2 输出里省略的那几项) |
-s | 不吞 print 输出 |
--durations=5 | 列出最慢的 5 个 |
覆盖率的陷阱:被替身换掉的代码
⑤ 的第 36 行就是例子:get_client 在所有测试里都被换掉了,它自己一次没执行过。替身越多,这种“看起来测了其实没测”的代码越多。补的办法是留一两个真起服务的冒烟测试,或者至少单测一下被替换的那个函数本身。
测超时,又不想真等
替身里 await asyncio.sleep(2) 能触发真的 asyncio.timeout(1.0),但每次多等 1 秒。常见做法是把超时时间做成可配置(依赖或参数),测试里配成 0.01 秒;时间相关的逻辑把“当前时间”也做成可注入的。
站 09 的评测也跑在 pytest 上
评测集的每条用例可以写成一组 parametrize,打分函数就是 assert;区别是 LLM 的输出有随机性,要统计通过率而不是要求全过。到站 09 再展开。
下一圈
- Hypothesis:自动生成上千条奇怪的 GC 日志行,找出
parse的反例。 - 快照测试(syrupy):把接口的 JSON 输出存成快照,变了就提醒。
- pytest 插件机制:
conftest.py里的钩子函数,比如给每个失败的测试自动附上请求日志。 - 测试替身的分类:stub、mock、fake、spy,各适合什么。
- CI 里跑测试:GitHub Actions + uv,站 09 的评测门禁从这里起步。
↺回到⑤:拿⑥改进值班助手的测试
1 · 参数边界
用 parametrize 给 minutes 写 0、1、60、61 四个边界,预期 422、200、200、422;再给 /healthz 补一个测试,看覆盖率里第 85 行消失。
用到:④-3、第二圈“一个测试只验证一件事”
2 · 测真的超时分支
让 fetch 的超时秒数可以配置,测试里设成 0.05 秒;替身对 order-04 await asyncio.sleep(1)。确认结果是 "timeout 1s" 那一类、整个测试不到 0.2 秒,第 57 行被覆盖。
用到:第三圈“测超时,又不想真等”
3 · 故意制造一次污染
把 agent_logs 改成 scope="session",跑一遍,找出哪个测试挂了、为什么单独跑又能过;再改回来。
用到:坑 4、实验台
1. 用一句话说出这一页的精华。
pytest 的测试是普通函数加普通 assert,assert 在导入时被改写成能解释失败原因的代码;fixture 按参数名注入、yield 前准备 yield 后收拾、作用域决定共用范围;配上 dependency_overrides 和 MockTransport,不起外部服务也能测完整条链路。
2. unittest 那种写法痛在哪?
要写类、继承 TestCase、记一堆 assertXxx;准备数据只有 setUp 一种粒度,不同测试要的东西不同时只能全准备一遍或拆类。
3. 坑 3“替身漏到下一个测试”是 ④ 哪条原理?
④-4:dependency_overrides 是挂在模块级 app 对象上的一张表,整个进程共用;不在 fixture 的收拾阶段清掉,后面所有测试都会看到它。
4. 预测:把 client fixture 改成 scope="module",agent_logs 保持默认,pytest 会怎样?
报 ScopeMismatch:大作用域的 fixture 不能依赖小作用域的 fixture,因为 module 级的 client 活得比 function 级的 agent_logs 长,没法每个测试换一份。
从 ④-3 的作用域规则推出。
5. 预测:test_summary_marks_hung_host 里把替身对 order-04 的处理从“抛 ReadTimeout”改成“返回状态码 500、内容是空字符串”,修好之后的 main.py 会给 order-04 什么结果?
error="no pauses"。httpx 拿到 500 不会抛异常(除非调用 raise_for_status()),r.text 是空字符串,交给 parse 就是“没有停顿”。这其实掩盖了“日志代理出错了”这件事,值得再补一个检查状态码的分支和测试。
从 ④-4(MockTransport 让 handler 决定返回什么)和 FastAPI 页“错误要用状态码表达”推出。
6. 放回全景:第 10 站和第 2~9 站是什么关系?如果没有 dependency_overrides,只能用 monkeypatch 换 main.fetch,测试会少测到哪些东西?
第 10 站用 TestClient 直接按 ASGI 调用同一个 app,第 2 站之后的路由、依赖、校验、gather、响应模型全部照常执行,只有拉日志的网络被换掉。
如果换的是 fetch 整个函数,那 fetch 里的 asyncio.timeout、两类异常处理、调用 parse 的那一步都不会执行。⑤ 里那个 httpx.ReadTimeout 的 bug 就是在 fetch 里,用这种办法根本抓不到。
见 全景第 10 站。