写过异步应用的人大概都遇到过这样的场景,程序启动时要建立数据库连接池,加载一个占内存的机器学习模型,或者预热一份配置缓存,程序关闭时又得把这些资源体面地释放掉。FastAPI把这套开关机逻辑叫做lifespan ,翻译过来就是应用生命周期。而测试这套逻辑,恰恰是很多新手容易踩坑的地方------因为普通的TestClient(app)用法根本不会触发这些启动关闭代码,你写的断言永远拿不到预期结果。这篇文档要讲的,正是如何正确地把lifespan和已经过时的startup/shutdown事件放进测试用例里 。
先搞清楚lifespan和事件是什么
在ASGI这套异步网关接口规范里,一个应用除了处理HTTP请求,还有一个专门的生命周期协议。简单说,应用从被服务器加载到真正开始接收请求之间,会有一段初始化窗口,应用从不再接收新请求到彻底退出之间,也会有一段收尾窗口。FastAPI把这两段窗口统一封装成一个异步上下文管理器,也就是lifespan函数 。
这个函数长这样,用yield把启动代码和关闭代码分隔开。
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
def fake_answer_to_everything_ml_model(x: float):
return x * 42
ml_models = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# yield之前是启动逻辑
ml_models["answer_to_everything"] = fake_answer_to_everything_ml_model
yield
# yield之后是关闭逻辑
ml_models.clear()
app = FastAPI(lifespan=lifespan)
yield前面的代码只会在应用真正启动那一刻执行一次,yield后面的代码只会在应用关闭那一刻执行一次,中间处理成千上万个请求,这两段代码都不会重复触发。这种写法其实和FastAPI里依赖注入使用yield的思路是一脉相承的,本质都是Python的上下文管理器模式 。
这里有个容易忽略的细节,如果你给应用传了lifespan参数,那老式的startup和shutdown装饰器事件就彻底失效了,两套机制不能混用,选一套就好 。
下面用一张图梳理一下这个流程。
为什么普通测试根本测不到这段逻辑
如果你只是写client = TestClient(app)然后直接发请求,FastAPI压根不会触发上面那套ASGI lifespan协议,因为这个协议是靠应用被正式启动这个事件触发的,而普通实例化并不算启动 。
Starlette官方文档里对这一点说得很直接,要在测试里真正跑起lifespan,必须把TestClient当成上下文管理器来用,也就是配合with语句,进入with代码块的那一刻,Starlette会在底层模拟发出ASGI的lifespan.startup消息,退出代码块时又会发出lifespan.shutdown消息 。这也是社区里不少人一开始百思不得其解的地方,明明代码写得没问题,测试却拿不到启动时该有的数据,根源往往就是漏了这个with 。
具体怎么实施
测试lifespan的写法
FastAPI官方给的示例非常清晰,用一个字典模拟数据存储,启动时填充数据,关闭时清空数据,然后在测试函数里分三个阶段做断言。
python
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
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():
# 进入with之前,lifespan还没跑,items是空的
assert items == {}
with TestClient(app) as client:
# 进入with代码块,lifespan的启动部分已经执行
assert items == {"foo": {"name": "Fighters"}, "bar": {"name": "Tenders"}}
response = client.get("/items/foo")
assert response.status_code == 200
assert response.json() == {"name": "Fighters"}
# 请求处理完,数据依然还在,因为还没到关闭阶段
assert items == {"foo": {"name": "Fighters"}, "bar": {"name": "Tenders"}}
# 退出with代码块,相当于模拟应用被关闭,清理逻辑跑了一遍
assert items == {}
这段代码把整个测试的时间线拆得明明白白,with语句块外面代表应用还没启动 ,块里面代表应用正在运行 ,块结束后代表应用已经关闭 。这种三段式断言其实特别适合用来验证资源管理是否正确,比如数据库连接池到底有没有被正常关掉,缓存有没有被正常清理。
测试已废弃的startup/shutdown事件
如果你维护的是老项目,还在用@app.on_event("startup")这种旧写法,同样可以配合with语句测试,逻辑是一样的。
python
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,代码更集中,也更符合Python原生的上下文管理器语义 。
涉及的核心概念梳理
把这几块知识拆开看会更清楚它们各自扮演的角色。
| 概念 | 作用 | 关键特征 |
|---|---|---|
| ASGI lifespan协议 | 定义应用启动关闭的标准消息格式 | 服务器和应用之间约定好的握手机制 |
| 异步上下文管理器 | 承载lifespan函数的语法结构 | 用@asynccontextmanager加yield实现 |
| TestClient的with语句 | 在测试中手动触发lifespan协议 | 进入代码块发startup,退出发shutdown |
| 共享状态 | 启动阶段准备好,请求间复用的资源 | 数据库连接池、机器学习模型、配置缓存都是常见例子 |
这四个概念其实是环环相扣的关系,ASGI协议定好了规矩,异步上下文管理器负责实现这套规矩里该做什么 ,TestClient的with语句则是在测试环境里手动扮演一次服务器的角色去触发这套规矩,而共享状态就是这一整套机制最终服务的对象 。
社区里也有人讨论过更进阶的场景,比如想在测试里替换掉真实的lifespan逻辑,换成一个mock版本,方便注入测试专用的假数据库连接,这种做法在Starlette的讨论区里有过详细案例,思路是自己写一个测试专用的lifespan函数,传给TestClient使用,从而避免真的去连接生产数据库 。
几个容易踩的坑
写测试的时候,有几件事特别容易被忽略。
- 忘记加
with。这是最常见的问题,直接TestClient(app).get(...)不会触发lifespan,很多人调试半天发现是这个原因 。 - 同时定义了
lifespan参数和on_event装饰器。前面提过,这两套机制互斥,混用只会让其中一套完全不生效 。 - 在异步测试环境里忘记搭配
AsyncClient或者对应的异步测试方案,导致同步的TestClient和异步的测试框架配合出问题,这一点在处理数据库连接池这类异步资源时尤其容易翻车 。
小结
测试lifespan这件事,说到底就是记住一句话,想在测试里跑通启动关闭逻辑,就得让TestClient以上下文管理器的身份出场 。搞懂ASGI协议里startup和shutdown两个消息是怎么被with语句触发的,剩下的写法基本就是照葫芦画瓢。无论是新式的lifespan还是老式的on_event,测试思路都一致,进入代码块前是空白状态,代码块里是运行状态,代码块结束是清理完毕的状态,把这三段状态分别断言清楚,测试就写得扎实了。
参考资料
Testing Events lifespan and startup shutdown FastAPI fastapi.tiangolo.com/advanced/te...
Lifespan Events FastAPI fastapi.tiangolo.com/advanced/ev...
Lifespan Starlette starlette.dev/lifespan/
How to test a FastAPI endpoint that uses lifespan function Stack Overflow stackoverflow.com/questions/7...
Lifespan patch mock in async testing GitHub Starlette Discussions github.com/Kludex/star...