使用单元IsolatedAsyncioTestCase测试fastapi路由时出现“运行时错误:事件循环已关闭”

问题描述 投票:0回答:1

考虑这个 mcve:

需求.txt:

fastapi
httpx
motor
pydantic[email]
python-bsonjs
uvicorn==0.24.0

main.py:

import asyncio
import unittest
from typing import Optional

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

# -------- Model --------
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": "[email protected]"}
        },
    )


# -------- 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_bar = await db_collection.insert_one(
        item.model_dump(by_alias=True, exclude=["id"])
    )
    created_bar = await db_collection.find_one({"_id": new_bar.inserted_id})
    return created_bar


@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")


if __name__ == "__main__":
    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")

    class TestAsync(unittest.IsolatedAsyncioTestCase):
        async def asyncSetUp(self):
            self.client = TestClient(app)

        async def asyncTearDown(self):
            self.client.app.db_client.close()

        def run_async_test(self, coro):
            loop = asyncio.get_event_loop()
            return loop.run_until_complete(coro)

        def test_show_item(self):
            bar_data = {"name": "John Doe", "email": "[email protected]"}
            create_response = 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)

            response = self.client.get(f"/item/{created_item_id}")
            self.assertEqual(response.status_code, 200)

    unittest.main()

当我尝试运行它时,我会遇到此崩溃:

(venv) d:\mcve>python mcve.py
E
======================================================================
ERROR: test_show_item (__main__.TestBarRoutesAsync.test_show_item)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\software\python\3.11.3-amd64\Lib\unittest\async_case.py", line 90, in _callTestMethod
    if self._callMaybeAsync(method) is not None:
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\unittest\async_case.py", line 112, in _callMaybeAsync
    return self._asyncioRunner.run(
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\asyncio\base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "d:\mcve\mcve.py", line 87, in test_show_item
    response = self.client.get(f"/item/{created_item_id}")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\starlette\testclient.py", line 502, in get
    return super().get(
           ^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 1055, in get
    return self.request(
           ^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\starlette\testclient.py", line 468, in request
    return super().request(
           ^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 828, in request
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 915, in send
    response = self._send_handling_auth(
               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 943, in _send_handling_auth
    response = self._send_handling_redirects(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 980, in _send_handling_redirects
    response = self._send_single_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\httpx\_client.py", line 1016, in _send_single_request
    response = transport.handle_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\starlette\testclient.py", line 344, in handle_request
    raise exc
  File "D:\mcve\venv\Lib\site-packages\starlette\testclient.py", line 341, in handle_request
    portal.call(self.app, scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\anyio\from_thread.py", line 288, in call
    return cast(T_Retval, self.start_task_soon(func, *args).result())
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\concurrent\futures\_base.py", line 456, in result
    return self.__get_result()
           ^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\concurrent\futures\_base.py", line 401, in __get_result
    raise self._exception
  File "D:\mcve\venv\Lib\site-packages\anyio\from_thread.py", line 217, in _call_func
    retval = await retval_or_awaitable
             ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\applications.py", line 116, in __call__
    await self.middleware_stack(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__
    raise exc
  File "D:\mcve\venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
    await self.app(scope, receive, _send)
  File "D:\mcve\venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\_exception_handler.py", line 55, in wrapped_app
    raise exc
  File "D:\mcve\venv\Lib\site-packages\starlette\_exception_handler.py", line 44, in wrapped_app
    await app(scope, receive, sender)
  File "D:\mcve\venv\Lib\site-packages\starlette\routing.py", line 746, in __call__
    await route.handle(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\routing.py", line 75, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "D:\mcve\venv\Lib\site-packages\starlette\_exception_handler.py", line 55, in wrapped_app
    raise exc
  File "D:\mcve\venv\Lib\site-packages\starlette\_exception_handler.py", line 44, in wrapped_app
    await app(scope, receive, sender)
  File "D:\mcve\venv\Lib\site-packages\starlette\routing.py", line 70, in app
    response = await func(request)
               ^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\fastapi\routing.py", line 299, in app
    raise e
  File "D:\mcve\venv\Lib\site-packages\fastapi\routing.py", line 294, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\fastapi\routing.py", line 191, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\mcve\mcve.py", line 57, in show_item
    if (item := await db_collection.find_one({"_id": ObjectId(id)})) is not None:
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\motor\metaprogramming.py", line 75, in method
    return framework.run_on_executor(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\mcve\venv\Lib\site-packages\motor\frameworks\asyncio\__init__.py", line 85, in run_on_executor
    return loop.run_in_executor(_EXECUTOR, functools.partial(fn, *args, **kwargs))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\software\python\3.11.3-amd64\Lib\asyncio\base_events.py", line 816, in run_in_executor
    self._check_closed()
  File "D:\software\python\3.11.3-amd64\Lib\asyncio\base_events.py", line 519, in _check_closed
    raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed

----------------------------------------------------------------------
Ran 1 test in 0.074s

FAILED (errors=1)

产生崩溃的线路是

response = self.client.get(f"/item/{created_item_id}")
,但我不明白问题是什么。

顺便说一句,对使用 pytest 根本不感兴趣,这个问题的主要目的是找出问题所在以及如何修复当前的 mcve

提前致谢!

python python-3.x unit-testing asynchronous fastapi
1个回答
0
投票

您没有与 /item/{created_item_id} 匹配的端点。然而你拥有的是 /{id} 。因此,您会收到一个隐蔽的 404 notfound 异常,它不会冒泡,但仍然会破坏测试场景,导致事件循环停止。

© www.soinside.com 2019 - 2024. All rights reserved.