跳转至

单独运行全绿,一起运行就红:谁把状态留在了现场

两个测试单独运行都通过,按下面顺序一起运行却失败:

test_create_plan_reports_topic_conflict  PASSED
test_create_plan_returns_public_plan     FAILED: expected 201, got 409

pytest 并没有突然“抽风”。第一个测试创建的 pytest 计划留在共享列表里,第二个 测试继承了不属于自己的前置条件。测试内容可能正确,测试环境却不再可重复。

运行环境

本章示例已在 Python 3.12.3、pytest 9.1.1 与 FastAPI 0.115.14 验证。它用 进程内可变 adapter 展示状态控制,不访问数据库或网络。

1. 先沿着状态的生命周期追查

假设应用在模块级创建一个共享 adapter:

class InMemoryPlanAdapter:
    def __init__(self):
        self.topics: set[str] = set()

adapter = InMemoryPlanAdapter()
app = create_app(adapter)

第一个测试向 adapter.topics 写入 pytest;测试结束后无人恢复。第二个测试的 前置条件本应是“topic 不存在”,实际却是“已经存在”。

概念|测试隔离 在相同受控前置条件下,每个测试单跑、连跑或换序都应得到相同语义结果;顺序 改变结果,说明某项状态没有被当前测试独立建立和恢复。

稳定测试不要求每次耗时完全相同,而要求相同受控前置条件产生一致语义结果。需要 控制的状态不只数据库,还可能是环境变量、时间、随机数、临时文件、缓存、应用 dependency override 和全局对象。

盲目重跑只能偶尔得到绿色,不能修复状态模型。真正的问题是:谁创建状态、谁可以 修改、共享多久、何时恢复?

2. 把准备工作交给 pytest 管

前面几章的测试都带着一个 client 参数,当时只说了一句:这个对象由 pytest 按 名字送进来。现在看它是怎么送进来的。

概念|pytest fixture 把测试所需的准备工作写成供给函数;测试在参数列表中写下名字,pytest 负责找到 函数、建立对象并交给测试。它不同于 W01-L01 中作为固定输入文件的数据 fixture。

做法很朴素:把准备工作写成一个普通函数,在上面加一行 @pytest.fixture。 这样的函数叫 fixture。哪个测试需要它,就在自己的参数列表里写下它的名字—— 不用 import,不用调用,也不用自己造一个。

真正的收益不是少写几行重复代码。上一节那个模块级 adapter 之所以出事,是 因为它什么时候创建、由谁清掉,代码里看不出来。写成 fixture 之后这两件事有了 负责人:pytest 在测试跑之前把它建好,跑完再收掉。

所以本节要回答的不是“怎么造出一个 client”,而是“这份状态由谁管、管多久”。 造 client 只是手段,上一节的顺序依赖才是要治的病。

供给方:三个各管一件事的 fixture

概念|conftest 可见性 pytest 会自动发现 conftest.py 中的 fixture,使该目录及子目录的测试无需 import 就能按参数名请求它们。

把 adapter、app 和 client 拆成三层,放进 tests/conftest.py

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient

from learning_assistant.app import create_app
from learning_assistant.adapters import InMemoryPlanAdapter

@pytest.fixture
def adapter():
    return InMemoryPlanAdapter()

@pytest.fixture
def app(adapter):
    return create_app(adapter)

@pytest.fixture
def client(app):
    with TestClient(app) as test_client:
        yield test_client

create_app(adapter) 是应用工厂:接收一个 adapter,返回已装配好路由与依赖的 FastAPI 实例。文件名 conftest.py 对 pytest 有特殊含义——放在其中的 fixture 对同目录及所有子目录下的测试文件自动可见,测试不需要 import 它们。这解释了 前两章的示例为什么能直接写 client 参数却看不到任何导入语句。

三个函数都不会被你的代码调用。它们是登记在册的供给者,等着有人报名字来取。

消费方:测试只报名字

概念|fixture 依赖图 fixture 可以通过自己的参数声明其他 fixture;pytest 从测试需要的名字出发, 沿依赖关系找到供给方,并按前置到后续的顺序建立上下文。

# tests/integration/test_plans_api.py
def test_create_plan_returns_public_plan(client):
    response = client.post(
        "/plans", json={"topic": "pytest", "weekly_hours": 8}
    )

    assert response.status_code == 201

测试函数只声明了一个参数 client,函数体里没有任何装配代码。这一行签名就是 完整的依赖声明。pytest 运行这个测试前会走完下面的链条:

1. 看到 test_create_plan_returns_public_plan 需要参数 client
2. 在 conftest.py 找到名为 client 的 fixture,发现它要 app
3. 找到 app fixture,发现它要 adapter
4. 找到 adapter fixture,它不再依赖别人 —— 链条到底

   执行顺序由此反向确定:
   adapter()  ->  app(adapter)  ->  client(app)  ->  注入测试

依赖是声明出来的,不是调用出来的。你只写“我要 client”,谁负责先造出 adapter 和 app 由 pytest 推导。想换掉整套上下文,改 fixture 即可,二十个测试的 签名一行都不用动。

两处写法上的细节

client fixture 用的是 yield 而不是 return。先只看它做了什么:yield 把它 后面的对象交给测试,函数在这里暂停;测试跑完,函数从暂停处继续往下跑——with 语句的退出就发生在这一刻。为什么非得写成这个形状、断言失败时它还灵不灵, 第 4 节再说。

with TestClient(app) as test_clientwith 也不是习惯性写法。TestClient 作为 context manager 使用时会触发应用的 lifespan(startup/shutdown);直接 TestClient(app) 则不会。W01-L02-P04 已经建立了 lifespan 的启动与清理配对语义;这里调用它来说明:路由测试通过不能 自动证明启动资源已经验证。

不要把主要业务动作藏进 fixture

@pytest.fixture
def conflicted_client(client):
    client.post("/plans", json=payload)  # 隐藏了关键 arrange,尚可争论
    client.post("/plans", json=payload)  # 主要 act 也被吃掉,测试正文失去意义
    return client

fixture 可以准备“已有计划”这一前置状态,但应命名清楚,且真正要观察的请求仍留 在测试正文。否则读者无法分辨哪个动作是被测行为。

3. 共享多久,不只是运行速度

概念|fixture scope fixture 的共享范围和重建时机。function 为每个测试实例建立一次;更大的范围 会复用同一对象,也会扩大可变状态泄漏的影响面。

pytest fixture 默认 scope="function":每个测试实例建立一次,结束后销毁。 其他常见 scope 包括 class、module、package 和 session。

@pytest.fixture(scope="session")
def settings():
    return FrozenSettings(app_name="learning-assistant")

不可变、创建成本稳定的配置可以考虑更大 scope。可变 adapter 若设为 session, 所有测试共享同一 topics 集合;运行可能少创建几个对象,却把隔离风险放大。

选择 scope 时先问:

  • 对象会被测试修改吗?
  • 修改能否可靠恢复?
  • 跨测试共享是否属于明确契约?
  • 创建成本真的高到值得承担共享复杂度吗?

本课的内存 adapter 很便宜且可变,function scope 是自然选择。把 session scope 当通用加速按钮,通常是用未来的 flaky test 换眼前几毫秒。

4. 清理必须在失败后也发生

概念|fixture teardown 测试结束后恢复 fixture 已建立上下文的清理阶段。yield fixture 会从 yield 之后继续执行,已经成功建立的依赖按相反顺序尝试清理,即使测试断言失败也一样。

某些上下文需要显式恢复,例如 dependency override:

@pytest.fixture
def client(app, adapter):
    app.dependency_overrides[get_plan_adapter] = lambda: adapter
    try:
        with TestClient(app) as test_client:
            yield test_client
    finally:
        app.dependency_overrides.clear()

第 2 节只说了 yield 会在测试结束后把控制权交回 fixture,现在补上它的关键保证: 这个“结束”不限于测试通过。pytest 的 yield fixture 在 yield 前建立资源,把值交给 测试,再在测试结束时执行后续 teardown。即使测试断言失败,已成功建立的 fixture 仍会按反向顺序尝试清理。

有个边界要记住:如果某 fixture 在到达 yield 之前就失败,它自己的 yield 后代码不会执行;但之前已成功建立的 fixture 仍会被 teardown。因此每个 fixture 最好只执行一个有状态动作,并立即为它提供相应清理,避免“建立五个资源后最后 一步失败,前四个无人负责”。

清理是否可靠,可以通过行为验证:

1. 单独运行每个测试;
2. 连续运行完整集合两次;
3. 交换两个共享边界测试的顺序;
4. 故意让中间断言失败,再运行下一例。

如果这些方式改变语义结果,就回到状态生命周期,而不是添加 sleep、retry 或放宽 断言。

常见误区

  • fixture 只是 DRY 工具。 它更重要的职责是声明可靠上下文与生命周期。
  • scope 越大越快越好。 对可变对象,越大也意味着越多状态泄漏机会。
  • autouse 最省事。 隐式执行会让前置条件不可见,应只用于真正全局且明确的约束。
  • 测试末尾手工清理就行。 断言一旦提前失败,末尾语句不会执行;清理应进入 fixture teardown 或 finally

本章小结

可重复性来自受控状态。fixture 通过显式参数和依赖图建立上下文;scope 定义共享 生命周期;yield/teardown 在成功和失败后恢复状态。每个测试都应能独立建立前置 条件,不依赖其他测试先运行。

练习

下面的 fixture 有哪些风险?请给出改造方向。

@pytest.fixture(scope="session", autouse=True)
def everything():
    adapter = InMemoryPlanAdapter()
    app = create_app(adapter)
    client = TestClient(app)
    client.post("/plans", json={"topic": "seed", "weekly_hours": 8})
    return client

练习解析

  • session scope 共享可变 adapter,任一测试修改都会影响后续测试;
  • autouse 隐藏依赖,即使不需要 client 的测试也会建立它;
  • fixture 执行了业务请求,测试看不到重要前置状态如何产生;
  • TestClient 没有使用 context manager,也没有显式清理;
  • adapter、app、client、seed 数据四项职责耦合,任一步失败都难以定位和清理。

应拆成 function-scoped adapterappclient;只有需要 seed 的测试显式请求 existing_plan fixture,且该 fixture 只建立前置状态。client 使用 context manager/yield,应用 override 在 finally 中恢复。

参考资料