首页
/ FastAPI 测试中的事件执行:用 TestClient 让 lifespan 与 startup/shutdown 真正跑起来

FastAPI 测试中的事件执行:用 TestClient 让 lifespan 与 startup/shutdown 真正跑起来

2026-09-07 13:18:05作者:齐添朝

导读

在编写 FastAPI 单元/集成测试时,一个常见痛点是:应用启动阶段准备好的资源(数据库连接、初始化数据、缓存预热)在测试里"不生效",因为测试进程并未真正启动服务。本文围绕仓库内文档 docs/es/docs/advanced/testing-events.md(对应英文源 docs/en/docs/advanced/testing-events.md)的核心知识点,讲解如何借助 TestClientwith 语句在测试中正确触发应用的生命周期事件,并对照仓库内源码与测试佐证底层机制,帮助你在测试中稳定复现"启动 → 请求 → 清理"的完整应用生命周期。

一、为什么测试里需要手动触发事件

FastAPI 应用的启动/关闭逻辑通常承载两类职责:

  • lifespan(现代推荐方式)或 on_event("startup") / on_event("shutdown")(已弃用)注册的处理器,用于在服务进程启动时做初始化、在进程退出时做清理;
  • 通过构造参数 FastAPI(lifespan=lifespan) 注入生命周期上下文管理器。

在日常运行中(如 fastapi run 或启动脚本里),生命周期由 ASGI 服务器负责触发。而在测试中,如果用"裸"的 TestClient(app) 直接发请求,不会进入任何启动流程——这时模块级全局变量可能是空的,初始化动作从未执行,测试自然失败。

仓库中本主题对应两个教程示例(位于 docs_src/app_testing):

两者的共同解法都是:TestClient 当作上下文管理器(with 语句)使用,让进入 with 块与离开 with 块这两个时机分别驱动"启动"与"关闭/清理"。

二、推荐做法:用 with TestClient 驱动 lifespan

先看完整的现代写法(docs_src/app_testing/tutorial004_py310.py):

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.testclient import TestClient

items = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    items["foo"] = {"name": "Fighters"}
    items["bar"] = {"name": "Tenders"}
    yield
    # clean up items
    items.clear()


app = FastAPI(lifespan=lifespan)


@app.get("/items/{item_id}")
async def read_items(item_id: str):
    return items[item_id]


def test_read_items():
    # Before the lifespan starts, "items" is still empty
    assert items == {}

    with TestClient(app) as client:
        # Inside the "with TestClient" block, the lifespan starts and items added
        assert items == {"foo": {"name": "Fighters"}, "bar": {"name": "Tenders"}}

        response = client.get("/items/foo")
        assert response.status_code == 200
        assert response.json() == {"name": "Fighters"}

        # After the requests is done, the items are still there
        assert items == {"foo": {"name": "Fighters"}, "bar": {"name": "Tenders"}}

    # The end of the "with TestClient" block simulates terminating the app, so
    # the lifespan ends and items are cleaned up
    assert items == {}

关键点逐一解读

  1. @asynccontextmanager 包装异步生成器lifespan 是一个异步上下文管理器,yield 之前的部分等价于旧式"启动事件",yield 之后的部分等价于"关闭事件"。这里启动时向全局字典 items 预置两条数据,结束时调用 items.clear() 做清理。

  2. 应用显式声明生命周期app = FastAPI(lifespan=lifespan)。从 fastapi/applications.py 可以看到,FastAPI.__init__ 会把 on_startupon_shutdownlifespan 一并透传给内部的 APIRouter(第 988-990 行),由路由层统一完成事件注册与调度,这也是 lifespan 最终能被触发的基础链路。

  3. 测试中"无条件启动"的验证手段:测试函数的断言顺序直观地刻画了生命周期时间线:

    • 进入 with 块之前:items == {}(启动逻辑尚未执行);
    • 进入 with 块之后:lifespanyield 之前的代码已运行,items 已有两条数据,随后请求 /items/foo 返回 200 与正确 JSON;
    • 请求结束后数据仍在(应用仍处于运行态,不会被中途清理);
    • 离开 with 块:等价于应用被终止,yield 之后的清理代码执行,items 再次回到空。
  4. 语义映射with TestClient(app) as client 的进入/退出,等价于真实服务器进程的"启动完成 → 处理请求 → 收到停机信号并清理退出",这是该方法能在测试中完整复现应用行为的本质原因。

三、旧式写法:为 startup / shutdown 事件编写测试

如果你维护的是仍在使用 @app.on_event("startup") 的存量代码,同样可以用 with 方式触发。完整示例见 docs_src/app_testing/tutorial003_py310.py

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

items = {}


@app.on_event("startup")
async def startup_event():
    items["foo"] = {"name": "Fighters"}
    items["bar"] = {"name": "Tenders"}


@app.get("/items/{item_id}")
async def read_items(item_id: str):
    return items[item_id]


def test_read_items():
    with TestClient(app) as client:
        response = client.get("/items/foo")
        assert response.status_code == 200
        assert response.json() == {"name": "Fighters"}

lifespan 版本相比:

  • 事件用装饰器 @app.on_event("startup") 注册,无需在 FastAPI() 构造参数里声明;
  • 测试结构一致——startup 处理器会在进入 with 块时执行,因此 client.get("/items/foo") 能命中 startup_event 预置的数据;
  • 该示例没有注册 shutdown 处理器,仅演示了启动方向;若需要清理逻辑,可另注册 @app.on_event("shutdown")

为什么新代码不再推荐这种方式

fastapi/applications.pyFastAPI.on_event 的源码注释可以读到明确态度:on_event 已被弃用(deprecated),官方建议改用 lifespan 事件处理器。弃用主要缘于旧式事件模型依赖隐式全局状态、且一旦在事件函数里出现异常会导致启动流程不明确,而 lifespan 用单个异步上下文管理器把"启动 + 关闭"收拢成一段可读、可控、异常可传播的代码。

仓库对弃用状态提供了可运行的证据:官方测试 tests/test_tutorial/test_testing/test_tutorial003.py 在导入 tutorial003 的测试函数时,显式用 pytest.warns(DeprecationWarning) 包裹,以此校验该写法会正确发出弃用警告;而对应 lifespan 版本的 tests/test_tutorial/test_testing/test_tutorial004.py 则无需任何警告断言,直接执行即可。

四、在仓库中运行与验证

本项目仓库中该主题的代码与测试都已就绪,可直接用 pytest 本地复现生命周期行为:

pytest tests/test_tutorial/test_testing/test_tutorial003.py -v
pytest tests/test_tutorial/test_testing/test_tutorial004.py -v

运行后应观察到:

  • test_tutorial004(lifespan 版)通过,不产生任何弃用警告;
  • test_tutorial003(旧式事件版)同样通过,且测试内部通过 pytest.warns(DeprecationWarning) 断言了弃用警告的存在,从测试层面再次印证 on_event 已不再被推荐。

五、补充测试链路:从事件覆盖到依赖覆盖

本主题(testing-events.md)属于 FastAPI 官方"进阶测试"章节。同一章节下的 Testing Dependencies with Overrides 介绍了另一类测试刚需——用 app.dependency_overrides 替换依赖。二者经常组合使用:例如在 with TestClient(app) 中请求真实启动流程的同时,将依赖(如外部支付/存储客户端)替换为测试替身。

实战组合范式(非仓库原文,属常规用法,请结合你的具体测试栈确认依赖注入细节):

def test_read_items_with_override():
    async def override_dependency():
        return {"name": "Stub"}

    app.dependency_overrides[original_dependency] = override_dependency
    with TestClient(app) as client:
        response = client.get("/items/foo")
        assert response.status_code == 200
    app.dependency_overrides.clear()  # 复位,避免污染其他测试

需要提醒的是:依赖覆盖与事件触发是两条正交机制,dependency_overrides 的挂载与清理务必在 with 块之外完成,确保每次测试的生命周期与覆盖状态都干净、可预测。

六、易错点与边界提示

  1. 不要漏掉 with:离开 with 块后,lifespan 的清理代码(yield 之后)才会执行;若只是裸调用 TestClient(app) 发请求,启动逻辑完全不会运行,依赖启动数据的用例会直接失败。
  2. 共享状态的作用域:教程里用模块级 items 演示,仅用于说明时序;真实项目请使用带作用域的资源(连接池、会话工厂等),并在 yield 之后集中释放,避免资源泄漏跨测试用例扩散。
  3. 弃用警告当作信号:如果你在自己的测试日志中看到 DeprecationWarning 指向 on_event,说明当前代码正在使用旧式事件 API。迁移时把 @app.on_event("startup") / @app.on_event("shutdown") 合并改写为一个 lifespan 异步上下文管理器,然后通过 FastAPI(lifespan=...) 注入即可,测试侧的 with TestClient(app) 写法无需改动。
  4. 异步事件处理器:本主题两个示例中的事件函数都声明为 async defTestClientwith 上下文中会自动等待异步事件完成(这也是其底层基于 ASGI 传输、运行事件循环的直接体现),因此即使初始化逻辑包含 await 的 I/O 操作也能被正确执行。

总结

在 FastAPI 测试中让应用生命周期"真正跑起来",答案就在一个 withwith TestClient(app) as client。进入块内,lifespanstartup 逻辑已就绪;离开块时,shutdown / yield 之后的清理逻辑得到执行。参照 docs_src/app_testing/tutorial004_py310.pylifespan 范式编写新代码,参照 docs_src/app_testing/tutorial003_py310.py 维护旧事件风格代码,并辅以仓库内的 test_tutorial003.pytest_tutorial004.py 进行回归验证,即可获得与真实进程行为高度一致的测试体验。

登录后查看全文
热门项目推荐
相关项目推荐