FastAPI的测试事件在测什么?

写过异步应用的人大概都遇到过这样的场景,程序启动时要建立数据库连接池,加载一个占内存的机器学习模型,或者预热一份配置缓存,程序关闭时又得把这些资源体面地释放掉。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装饰器事件就彻底失效了,两套机制不能混用,选一套就好 。

下面用一张图梳理一下这个流程。

flowchart TD A[服务器加载应用] --> B(执行lifespan函数yield之前的代码) B --> C[应用进入运行状态开始接收请求] C --> D[处理请求1] C --> E[处理请求2] C --> F[处理请求N] D --> G[服务器收到关闭信号] E --> G F --> G G --> H(执行lifespan函数yield之后的代码) H --> I[应用彻底退出]

为什么普通测试根本测不到这段逻辑

如果你只是写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...

相关推荐
猿究院_夏燊6 小时前
Spring Bean 生命周期
java·后端·spring
信誓旦旦的程序猿7 小时前
【Python量化实战宝典 #08】可转债与债券接口手册:转股价·溢价率·纯债价值(Python 可跑)
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
砚底藏山河7 小时前
python量化:行情数据回溯
java·开发语言·python·金融·maven
今年下半年7 小时前
AI智能体项目实战记录(02):初始化 Python 项目
开发语言·人工智能·python·fastapi
Williamtym7 小时前
PickGo Lab实践:用浏览器、Python和MCP串起机器人搬运仿真
开发语言·python·机器人
CS创新实验室8 小时前
Python 对象“死“了之后去了哪?——引用计数与分代垃圾回收
开发语言·jvm·python
CCblandy8 小时前
雪花 ID 四个边界实测:时钟回拨重号 500 个,前端 JSON.parse 直接丢精度
后端·python·算法·性能优化·架构
我是人✓8 小时前
SpringBoot项目的五种创建方式
java·spring boot·后端
quantdash_cc8 小时前
Pandas 处理分钟级股票行情:从原始数据清洗到 K 线聚合
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
把我格子衫拿来8 小时前
VTK 9.5 与 Qt 6 联合开发:从嵌入三维窗口到可维护的桌面架构
c++·python·qt·vtk·三维可视化