FastAPI 测试中的事件执行:用 TestClient 让 lifespan 与 startup/shutdown 真正跑起来
导读
在编写 FastAPI 单元/集成测试时,一个常见痛点是:应用启动阶段准备好的资源(数据库连接、初始化数据、缓存预热)在测试里"不生效",因为测试进程并未真正启动服务。本文围绕仓库内文档 docs/es/docs/advanced/testing-events.md(对应英文源 docs/en/docs/advanced/testing-events.md)的核心知识点,讲解如何借助 TestClient 的 with 语句在测试中正确触发应用的生命周期事件,并对照仓库内源码与测试佐证底层机制,帮助你在测试中稳定复现"启动 → 请求 → 清理"的完整应用生命周期。
一、为什么测试里需要手动触发事件
FastAPI 应用的启动/关闭逻辑通常承载两类职责:
- 用
lifespan(现代推荐方式)或on_event("startup")/on_event("shutdown")(已弃用)注册的处理器,用于在服务进程启动时做初始化、在进程退出时做清理; - 通过构造参数
FastAPI(lifespan=lifespan)注入生命周期上下文管理器。
在日常运行中(如 fastapi run 或启动脚本里),生命周期由 ASGI 服务器负责触发。而在测试中,如果用"裸"的 TestClient(app) 直接发请求,不会进入任何启动流程——这时模块级全局变量可能是空的,初始化动作从未执行,测试自然失败。
仓库中本主题对应两个教程示例(位于 docs_src/app_testing):
- docs_src/app_testing/tutorial004_py310.py:使用
lifespan的推荐写法; - docs_src/app_testing/tutorial003_py310.py:使用已弃用
startup/shutdown事件的旧式写法。
两者的共同解法都是:把 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 == {}
关键点逐一解读
-
@asynccontextmanager包装异步生成器:lifespan是一个异步上下文管理器,yield之前的部分等价于旧式"启动事件",yield之后的部分等价于"关闭事件"。这里启动时向全局字典items预置两条数据,结束时调用items.clear()做清理。 -
应用显式声明生命周期:
app = FastAPI(lifespan=lifespan)。从 fastapi/applications.py 可以看到,FastAPI.__init__会把on_startup、on_shutdown与lifespan一并透传给内部的APIRouter(第 988-990 行),由路由层统一完成事件注册与调度,这也是lifespan最终能被触发的基础链路。 -
测试中"无条件启动"的验证手段:测试函数的断言顺序直观地刻画了生命周期时间线:
- 进入
with块之前:items == {}(启动逻辑尚未执行); - 进入
with块之后:lifespan中yield之前的代码已运行,items已有两条数据,随后请求/items/foo返回 200 与正确 JSON; - 请求结束后数据仍在(应用仍处于运行态,不会被中途清理);
- 离开
with块:等价于应用被终止,yield之后的清理代码执行,items再次回到空。
- 进入
-
语义映射:
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.py 中 FastAPI.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 块之外完成,确保每次测试的生命周期与覆盖状态都干净、可预测。
六、易错点与边界提示
- 不要漏掉
with:离开with块后,lifespan的清理代码(yield之后)才会执行;若只是裸调用TestClient(app)发请求,启动逻辑完全不会运行,依赖启动数据的用例会直接失败。 - 共享状态的作用域:教程里用模块级
items演示,仅用于说明时序;真实项目请使用带作用域的资源(连接池、会话工厂等),并在yield之后集中释放,避免资源泄漏跨测试用例扩散。 - 弃用警告当作信号:如果你在自己的测试日志中看到
DeprecationWarning指向on_event,说明当前代码正在使用旧式事件 API。迁移时把@app.on_event("startup")/@app.on_event("shutdown")合并改写为一个lifespan异步上下文管理器,然后通过FastAPI(lifespan=...)注入即可,测试侧的with TestClient(app)写法无需改动。 - 异步事件处理器:本主题两个示例中的事件函数都声明为
async def,TestClient在with上下文中会自动等待异步事件完成(这也是其底层基于 ASGI 传输、运行事件循环的直接体现),因此即使初始化逻辑包含await的 I/O 操作也能被正确执行。
总结
在 FastAPI 测试中让应用生命周期"真正跑起来",答案就在一个 with:with TestClient(app) as client。进入块内,lifespan 或 startup 逻辑已就绪;离开块时,shutdown / yield 之后的清理逻辑得到执行。参照 docs_src/app_testing/tutorial004_py310.py 的 lifespan 范式编写新代码,参照 docs_src/app_testing/tutorial003_py310.py 维护旧事件风格代码,并辅以仓库内的 test_tutorial003.py 与 test_tutorial004.py 进行回归验证,即可获得与真实进程行为高度一致的测试体验。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00