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参数,那老式的startupshutdown装饰器事件就彻底失效了,两套机制不能混用,选一套就好 。

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

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函数的语法结构 @asynccontextmanageryield实现
TestClient的with语句 在测试中手动触发lifespan协议 进入代码块发startup,退出发shutdown
共享状态 启动阶段准备好,请求间复用的资源 数据库连接池、机器学习模型、配置缓存都是常见例子

这四个概念其实是环环相扣的关系,ASGI协议定好了规矩,异步上下文管理器负责实现这套规矩里该做什么TestClientwith语句则是在测试环境里手动扮演一次服务器的角色去触发这套规矩,而共享状态就是这一整套机制最终服务的对象 。

社区里也有人讨论过更进阶的场景,比如想在测试里替换掉真实的lifespan逻辑,换成一个mock版本,方便注入测试专用的假数据库连接,这种做法在Starlette的讨论区里有过详细案例,思路是自己写一个测试专用的lifespan函数,传给TestClient使用,从而避免真的去连接生产数据库 。


几个容易踩的坑

写测试的时候,有几件事特别容易被忽略。

  • 忘记加with。这是最常见的问题,直接TestClient(app).get(...)不会触发lifespan,很多人调试半天发现是这个原因 。
  • 同时定义了lifespan参数和on_event装饰器。前面提过,这两套机制互斥,混用只会让其中一套完全不生效 。
  • 在异步测试环境里忘记搭配AsyncClient或者对应的异步测试方案,导致同步的TestClient和异步的测试框架配合出问题,这一点在处理数据库连接池这类异步资源时尤其容易翻车 。

小结

测试lifespan这件事,说到底就是记住一句话,想在测试里跑通启动关闭逻辑,就得让TestClient以上下文管理器的身份出场 。搞懂ASGI协议里startupshutdown两个消息是怎么被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...

相关推荐
gis开发之家1 小时前
日志怎么打才专业?Spring Boot 4 日志体系与 Logback 配置(生产级实战)
java·spring boot·后端·logback
selfsongs1 小时前
Python学习之——multiprocessing 多进程编程
python
卷无止境1 小时前
FastAPI CLI 你需要认识这把命令行利器
后端·python·fastapi
云泽8081 小时前
Python 开发环境搭建全指南:从 Python 安装到 PyCharm 配置详解
开发语言·python·pycharm
IT小白杨1 小时前
海外业务账号安全保障:主流浏览器产品底层机制对比
数据库·python·安全·自动化·安全架构·指纹浏览器
ZC跨境爬虫1 小时前
LeetCode 119. 杨辉三角 II(原地更新优化详解 + Java Python 实现)
java·python·leetcode
AINative软件工程1 小时前
LLM 请求合并工程实践:用 Single-Flight 把并发重复调用从 N 次砍成 1 次
后端·llm·ai编程
程序员爱钓鱼1 小时前
Go 编程实战:数组 Array——固定长度的数据集合
后端·面试·go
jay神3 小时前
深度学习的优化器应该怎么选?
人工智能·python·深度学习·毕业设计·课程设计