解决FastAPI异步测试中“Event loop is closed”错误

心靈之曲
发布: 2025-11-30 13:17:02
原创
476人浏览过

解决fastapi异步测试中“event loop is closed”错误

本文旨在解决在使用unittest.IsolatedAsyncioTestCase测试FastAPI异步路由时遇到的“RuntimeError: Event loop is closed”问题。核心方案是分离FastAPI应用与测试代码,并采用正确的异步测试结构,确保每个异步测试逻辑在独立的事件循环中运行,从而避免事件循环冲突和关闭错误。

引言:FastAPI异步测试中的事件循环关闭问题

在开发基于FastAPI的异步应用时,我们经常需要对路由进行单元测试。当应用中包含异步操作(例如与MongoDB使用Motor进行交互)时,通常会选择unittest模块提供的IsolatedAsyncioTestCase来编写异步测试用例。然而,一个常见的痛点是遇到RuntimeError: Event loop is closed错误,尤其是在使用TestClient进行多个异步请求时。这个错误表明测试代码尝试在一个已经关闭的或不正确的事件循环上执行异步操作,导致测试失败。

问题根源分析

RuntimeError: Event loop is closed错误通常发生在以下几种情况:

  1. 事件循环生命周期管理不当:unittest.IsolatedAsyncioTestCase旨在为每个异步测试方法提供一个独立的、隔离的事件循环。如果在非异步的测试方法中手动获取并运行事件循环,或者在异步方法中对事件循环进行不当操作(如多次关闭),就可能导致后续的异步操作找不到可用的事件循环。
  2. 同步方法中执行异步操作:在原始代码中,test_show_item方法本身是一个同步方法,但它内部调用了self.client.post和self.client.get,这些TestClient的方法在底层会尝试执行异步操作。当一个同步方法尝试在一个由IsolatedAsyncioTestCase管理的异步环境中运行异步代码时,如果没有正确地桥接同步与异步上下文,就容易出现事件循环问题。
  3. TestClient与anyio的交互:FastAPI的TestClient内部依赖anyio库来处理异步请求。anyio会管理其自己的任务和事件循环上下文。当IsolatedAsyncioTestCase和anyio都在尝试管理或使用事件循环时,如果协调不当,便可能导致冲突。原始代码中的run_async_test方法试图通过asyncio.get_event_loop().run_until_complete(coro)来运行协程,这可能获取到错误的事件循环,或者在IsolatedAsyncioTestCase已经关闭其循环后再次尝试使用。

解决方案:优化FastAPI应用与测试结构

解决此问题的关键在于明确划分FastAPI应用代码与测试代码的职责,并遵循unittest.IsolatedAsyncioTestCase的正确使用范式。

原则一:分离应用与测试代码

将FastAPI应用及其配置(如数据库连接)从测试文件中分离出来,是良好的软件工程实践。这使得应用可以独立运行,测试代码可以独立导入并测试应用的不同部分。

1. 创建 app.py 文件 (FastAPI 应用)

将FastAPI应用的所有路由、模型和应用初始化逻辑放入一个单独的文件,例如 app.py。移除所有与unittest相关的代码。

# app.py
from typing import Optional

import motor.motor_asyncio
import uvicorn
from bson import ObjectId
from fastapi import APIRouter, Body, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic.functional_validators import BeforeValidator
from typing_extensions import Annotated

# -------- Model --------
# 定义PyObjectId类型,用于处理MongoDB的ObjectId
PyObjectId = Annotated[str, BeforeValidator(str)]

class ItemModel(BaseModel):
    id: Optional[PyObjectId] = Field(alias="_id", default=None)
    name: str = Field(...)
    email: EmailStr = Field(...)
    model_config = ConfigDict(
        populate_by_name=True,
        arbitrary_types_allowed=True,
        json_schema_extra={
            "example": {"name": "Jane Doe", "email": "jane.doe@example.com"}
        },
    )

# -------- Router --------
mcve_router = APIRouter()

@mcve_router.post(
    "",
    response_description="Add new item",
    response_model=ItemModel,
    status_code=status.HTTP_201_CREATED,
    response_model_by_alias=False,
)
async def create_item(request: Request, item: ItemModel = Body(...)):
    db_collection = request.app.db_collection
    new_item = await db_collection.insert_one(
        item.model_dump(by_alias=True, exclude=["id"])
    )
    created_item = await db_collection.find_one({"_id": new_item.inserted_id})
    return created_item

@mcve_router.get(
    "/{id}",
    response_description="Get a single item",
    response_model=ItemModel,
    response_model_by_alias=False,
)
async def show_item(request: Request, id: str):
    db_collection = request.app.db_collection
    if (item := await db_collection.find_one({"_id": ObjectId(id)})) is not None:
        return item

    raise HTTPException(status_code=404, detail=f"Item {id} not found")

# FastAPI 应用实例
app = FastAPI()
app.include_router(mcve_router, tags=["item"], prefix="/item")

# 数据库客户端和集合配置
app.db_client = motor.motor_asyncio.AsyncIOMotorClient(
    "mongodb://127.0.0.1:27017/?readPreference=primary&appname=MongoDB%20Compass&ssl=false"
)
app.db = app.db_client.mcve_db
app.db_collection = app.db.get_collection("bars")

# 应用启动入口 (用于开发和生产环境)
if __name__ == '__main__':
    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
登录后复制

原则二:正确编写异步测试用例

在测试文件中,导入FastAPI应用实例,并确保所有涉及异步操作的测试方法都被正确标记为async,并使用await关键字。

2. 创建 test_app.py 文件 (单元测试)

# test_app.py
import asyncio
import unittest
from fastapi.testclient import TestClient
from app import app # 从 app.py 导入 FastAPI 应用实例

class TestAsync(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        """
        在每个测试方法运行前异步设置测试环境。
        这里初始化 FastAPI TestClient。
        """
        self.client = TestClient(app)

    async def asyncTearDown(self):
        """
        在每个测试方法运行后异步清理测试环境。
        这里关闭 MongoDB 客户端连接。
        """
        self.client.app.db_client.close()
        # 清理数据库(可选,但推荐在实际项目中进行)
        # await self.client.app.db_collection.delete_many({})

    async def run_async_test(self, coro):
        """
        辅助方法,用于在 IsolatedAsyncioTestCase 的上下文中运行一个协程。
        asyncio.run() 会为每次调用创建一个新的事件循环,确保隔离性。
        """
        return asyncio.run(coro)

    async def test_show_item(self):
        """
        测试创建和获取 Item 的异步流程。
        整个测试逻辑被封装在一个异步函数中,并通过 run_async_test 执行。
        """
        async def test_logic():
            bar_data = {"name": "John Doe", "email": "john.doe@example.com"}

            # 异步发送 POST 请求创建 Item
            create_response = await self.client.post("/item", json=bar_data)
            self.assertEqual(create_response.status_code, 201)

            created_item_id = create_response.json().get("id")
            self.assertIsNotNone(created_item_id)

            # 异步发送 GET 请求获取已创建的 Item
            response = await self.client.get(f"/item/{created_item_id}")
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.json().get("name"), "John Doe")
            self.assertEqual(response.json().get("email"), "john.doe@example.com")
            self.assertEqual(response.json().get("id"), created_item_id)

        # 运行异步测试逻辑
        await self.run_async_test(test_logic())

if __name__ == "__main__":
    unittest.main()
登录后复制

关键改进点解释:

  1. async def asyncSetUp(self) 和 async def asyncTearDown(self): IsolatedAsyncioTestCase 提供了这些异步的设置和清理方法,确保在每个测试用例运行前后,可以执行异步操作(例如初始化TestClient或关闭数据库连接)。
  2. async def run_async_test(self, coro): 这个辅助方法是关键。它使用asyncio.run(coro)来运行传入的协程。asyncio.run() 的一个重要特性是它会为每次调用创建一个新的事件循环,并在协程完成后关闭它。这确保了每个测试逻辑的执行都在一个干净、隔离的事件循环中,避免了事件循环冲突。
  3. async def test_show_item(self): 测试方法本身被标记为async。这使得它能够在IsolatedAsyncioTestCase的异步上下文中运行。
  4. async def test_logic(): 将实际的测试步骤封装在一个内部的异步函数test_logic中。这样做的好处是,test_logic内部可以直接使用await关键字调用self.client.post和self.client.get等异步方法。
  5. await self.run_async_test(test_logic()): 在test_show_item中,我们await调用self.run_async_test(test_logic())。这意味着test_logic中的所有异步操作都会在一个由asyncio.run创建的独立事件循环中执行,而这个asyncio.run本身又是在IsolatedAsyncioTestCase提供的事件循环中被await的。这种嵌套确保了隔离性和正确的事件循环管理。

环境配置与运行

为了运行上述代码,您需要安装以下依赖:

BRANDMARK
BRANDMARK

AI帮你设计Logo、图标、名片、模板……等

BRANDMARK 180
查看详情 BRANDMARK

requirements.txt:

fastapi
httpx
motor
pydantic[email]
python-bsonjs
uvicorn==0.24.0
登录后复制

安装依赖:

pip install -r requirements.txt
登录后复制

MongoDB 设置:

确保您的本地运行着MongoDB实例。如果没有,您可以参考MongoDB官方文档进行安装:MongoDB Installation Guide

运行测试:

在包含 app.py 和 test_app.py 文件的目录下,执行以下命令来运行单元测试:

python -m unittest test_app.py
登录后复制

如果一切配置正确,您将看到测试成功通过,不再出现“Event loop is closed”错误。

总结与最佳实践

通过上述修改,我们成功解决了在unittest.IsolatedAsyncioTestCase中测试FastAPI异步路由时遇到的“RuntimeError: Event loop is closed”问题。核心思想是:

  • 分离职责:将FastAPI应用逻辑与测试逻辑完全分离,提高模块化程度。
  • 正确使用异步测试框架:充分利用unittest.IsolatedAsyncioTestCase提供的asyncSetUp、asyncTearDown和async测试方法。
  • 事件循环隔离:通过在测试方法内部使用asyncio.run()来执行核心测试逻辑,确保每个测试用例都在一个独立的事件循环中运行,从而避免了事件循环的冲突和不当关闭。
  • 明确异步调用:在异步测试方法中,所有异步操作(包括TestClient的请求)都应使用await关键字。

遵循这些最佳实践,可以有效地构建健壮、可维护的FastAPI异步应用测试套件。

以上就是解决FastAPI异步测试中“Event loop is closed”错误的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号