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 站的路由、校验和序列化,只把最外面拉日志那一下换成假的,假数据想造什么样就造什么样。

线上 curl uvicorn :8000 同一个 app:第 2~9 站路由、依赖、校验、gather、响应模型 get_client真的 AsyncClient 日志代理 :9100 测试(第 10 站) TestClient(main.app)不开端口,直接按 ASGI 调用 同一个 app:第 2~9 站一行都没改 overrides换成假客户端 agent_logsfixture 里的字典 请求不出进程:MockTransport 的 handler 直接从字典里拿假日志,order-04 设成“卡住”。10 个测试 0.11 s 跑完 实测
需求工作要用:站 04 开始,每个工具、每次改提示词都要靠测试和评测兜底;站 09 的评测进 CI,用的也是 pytest。⑤ 要能直接抄,⑥ 先讲最容易让测试“时过时不过”的坑。

②老办法:JUnit 你很熟,Python 自带的 unittest 长得和它几乎一样

JUnit 5 + Mockito
@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());
  }
}
类、注解、容器、Mock 框架,一层套一层。
unittest 照搬 JUnit 3 的风格
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)
必须写类、继承 TestCase,断言要记一堆 assertXxx;共享准备只有 setUp 这一种粒度。
pytest 普通函数 + 普通 assert
def test_max(agent_logs):
    h = parse("order-01", agent_logs["order-01"])
    assert h.max_ms == 244.05
    assert h.error is None
要什么写在参数里,断言就是 Python 的 assert,失败时 pytest 自己把值打出来。

③选谁: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

沿途目录里的 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,但按需、可组合

def test_summary_...(client): 参数名 client → 去找同名 fixture @pytest.fixture def client(agent_logs): 设 dependency_overrides with TestClient(app) as c: yield c ← 测试在这里跑 overrides.clear() yield 之后的代码:测试失败也会执行 def agent_logs(): 先造它:client 依赖它 测试自己也能要 agent_logs: def test_x(client, agent_logs): 同一个测试里,两处拿到的是同一个字典 (每个 fixture 在一次测试里只造一次)
fixture 可以依赖别的 fixture,pytest 按参数名把它们串成一张图,先造被依赖的。作用域(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 都断言这条日志不为空。

scope
test_1 做了什么

先猜:三个测试里会挂几个?

4换掉外部依赖的三层办法:越靠近边界越好 ≈ @MockBean、WireMock、反射改字段

dependency_overrides(换依赖)

app.dependency_overrides[get_client] = 假的。FastAPI 调依赖时先查这张表。只对 FastAPI 的 Depends 有效,用完要 clear()。≈ @MockBean。

MockTransport(换网络)

真的 httpx.AsyncClient,只是传输层换成一个函数:请求不出进程,由 handler 决定返回什么,或者抛什么异常。≈ WireMock,但在进程里。

monkeypatch(换名字)

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 / Springpytest说明
@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
@MockBeanapp.dependency_overrides[dep] = fakeFastAPI
MockMvcTestClient(app)要写 with 才跑 lifespan
@TempDir Path dirdef test_x(tmp_path):内置 fixture
mvn test -Dtest=X#ypytest 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,不进生产依赖
1pyproject.toml 末尾加两行配置
[tool.pytest.ini_options]
pythonpath = ["."]        # 让 tests/ 里能 import main
testpaths = ["tests"]     # 只在 tests/ 下找测试
2tests/conftest.py 两个 fixture:假日志数据,和接好替身的 TestClient
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()     # 收尾:别让替身漏到别的测试
3tests/test_parse.py 纯函数 + 参数化
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
4tests/test_api.py 走完第 2~9 站
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
第一次跑就红了,而且红得对。假日志代理让 order-04 抛 httpx.ReadTimeout,模拟的是“httpx 自己的超时先到了”。fetch 只接住了 asyncio.timeout 抛的 TimeoutError,而 httpx.ReadTimeout 不是它的子类,异常冒到 gather,整个接口 500。全景页预测题 2 推出过这件事,现在被测试实打实抓到了。
5修 main.py 的 fetch:多接一类异常
    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

实测 本机 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。

到这里已经能交差了:纯函数测试、参数化、fixture、换掉网络测接口、覆盖率,一个真 bug 已经修掉。
已知还没处理的细节
  • 第 57 行的真超时分支怎么测,又不让测试等 1 秒 → ↺ 练习 2
  • 替身忘了清掉会怎样 → ⑥ 坑 3
  • 要直接测 async 函数 → ⑥ 坑 7

⑥细节·规范·优化

第一圈 8 个坑,7 个在本机复现过 实测,第 8 个在闭包和装饰器那页复现过。

第一圈

注意事项:不知道就会出事

1文件或函数没叫 test_,一个都没跑← ④-1 按名字收集
现象
check_parse.py、def parse_test()、class GcTests 都不会被收集。输出 no tests ran,退出码 5。
根源
pytest 只按名字规则找测试,名字不对就当普通代码。
做法
统一 tests/test_*.py、def test_*、class Test*;CI 里把退出码 5 也当失败。
2TestClient 没写 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。
3替身没清掉,漏到下一个测试← ④-4 overrides 是全局的
现象
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),自动还原。
4共享作用域的 fixture 被改了← ④-3 作用域
现象
scope="module" 的字典被 test_1 改了,同文件的 test_2 挂;单独跑 test_2 通过。换成 session,另一个文件的 test_3 也挂。见实验台。
根源
作用域越大,共用这一个对象的测试越多。
做法
可变数据用默认的 function 作用域;大作用域只放只读或昂贵的东西(数据库连接、加载好的模型),并且返回只读视图或副本。
5浮点数用 == 比较设计如此
现象
assert 0.1 + 0.2 == 0.3 失败。平均停顿这种算出来的数尤其容易中招。
根源
二进制浮点数表示不了 0.1,和 Java 的 double 一样。
做法
assert avg == pytest.approx(96.4, abs=0.05)。
6assert (条件, "说明") 永远通过← ④-2 assert 语法
现象
assert (1 == 2, "never fails") 通过了,只留下一条 PytestAssertRewriteWarning: assertion is always true。
根源
括号把两样东西包成一个非空元组,非空元组永远为真。
做法
写成 assert 1 == 2, "说明",不加括号。
7直接写 async def test_...,跑不起来设计如此
现象
不装插件时报 async def functions are not natively supported,测试失败。
根源
pytest 本身不带事件循环,协程函数调用了只会得到协程对象。
做法
FastAPI 已经带了 anyio,它自带 pytest 插件:加 @pytest.mark.anyio 就能跑 实测;也可以装 pytest-asyncio。测接口用同步的 TestClient 就够了。
8monkeypatch 改错了模块← ④-4 换名字
现象
改了 main.fetch,用 from main import fetch 的模块还在调真的。
根源
每个模块各查各的字典,见闭包和装饰器页坑 7(那页实测)。
做法
能用 dependency_overrides 或 MockTransport 就别 patch;要 patch 就 patch 调用方查名字的那个模块。
第二圈

使用规范:团队里怎么写

一个测试只验证一件事

名字写清楚验证什么:test_unknown_service_is_404。

挂了要读半天才知道哪儿坏了。

替身放在最外面那层

优先换网络(MockTransport),其次换依赖,最后才 monkeypatch。

替身换得太里面,真代码其实没测到。

共享准备写进 conftest.py

按目录分层:tests/conftest.py 放全局的,tests/api/conftest.py 放接口专用的。

每个文件复制一份准备代码。

测试之间不能有顺序依赖

任何一个测试单独跑都要能过;CI 里可以加 pytest-randomly 打乱顺序。

坑 3、坑 4 这类“时过时不过”。

测试不碰真网络

需要真服务的放进单独的目录或标记(@pytest.mark.integration),默认不跑。

测试慢、受别人的服务影响而随机失败。

CI 里跑覆盖率,但不追 100%

看“没覆盖的是哪几行、为什么”,像 ⑤ 里那样逐行解释。

为了数字写一堆不验证任何东西的测试。

第三圈

优化和深处

常用命令

-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 站。