修复 pytest-asyncio 事件循环冲突:完整解决方案

当你使用 pytest-asyncio 测试 FastAPI/SQLAlchemy 应用时,可能会遇到这样的错误:

复制代码
Task got Future <Future pending cb=[BaseProtocol._on_waiter_completed()]> attached to a different loop

这是一个经典且令人困惑 的问题。本文将深入分析其根本原因,并提供一个通用的解决方案


问题现象

复现条件

以下条件同时满足时,问题会出现:

  1. 使用 pytest-asyncio 运行异步测试
  2. 使用 SQLAlchemy + asyncpg(或任何异步数据库驱动)
  3. 数据库 engine 是 session 级别的 fixture
  4. 测试方法使用了 @pytest.mark.asyncio(loop_scope="function")(或默认行为)

典型错误信息

python 复制代码
Future <Future pending cb=[BaseProtocol._on_waiter_completed()]> attached to a different loop

或:

python 复制代码
RuntimeError: Task got Future attached to a different loop

特征

  • 单独运行测试时通过pytest tests/test_foo.py::TestFoo::test_bar -v
  • 批量运行时失败pytest tests/test_foo.py -v
  • 第二个及之后的测试方法失败,第一个通常通过

根本原因

asyncio 对象归属规则

在 Python asyncio 中,Future 和 Task 对象一旦绑定到某个事件循环,就只能在该循环中使用。尝试在另一个循环中 await 它会直接报错。

三层循环冲突

让我们看看问题发生时的架构:

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    pytest session                           │
├─────────────────────────────────────────────────────────────┤
│  session 级别的 pg_engine fixture                           │
│       └── 在 Loop A 中初始化                                 │
│            └── SQLAlchemy + asyncpg 创建连接池               │
│                 └── 所有连接持有 Loop A 的 Future 对象       │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│               test_method_two() 第二个测试                   │
├─────────────────────────────────────────────────────────────┤
│  pytest-asyncio 为每个测试创建新循环(loop_scope="function")│
│       └── 创建 Loop B(新循环!)                            │
│            └── 测试代码请求数据库                             │
│                 └── 尝试使用 pg_engine 的连接                │
│                      💥 连接持有 Loop A 的 Future            │
│                      但当前是 Loop B → 冲突!                │
└─────────────────────────────────────────────────────────────┘

为什么单独运行测试通过?

单独运行时:

  • 第一个(也是唯一一个)测试初始化 pg_engine 时绑定了 Loop A
  • 没有后续测试创建新循环
  • 不存在跨循环问题

批量运行时:

  • 测试 1 完成后,pg_engine 仍然持有 Loop A 的连接
  • 测试 2 开始,创建新 Loop B
  • 尝试使用 Loop A 的连接 → 冲突

通用解决方案

方案概述

核心思想:让所有组件(engine、fixtures、测试方法)共享同一个事件循环。

配置步骤

步骤 1:配置 pyproject.toml(或 pytest.ini)

在项目根目录的 pyproject.toml 中添加:

toml 复制代码
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"

如果是 pytest.ini

ini 复制代码
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session
步骤 2:删除手动的 event_loop fixture

如果你有这样的代码,删除它

python 复制代码
# ❌ 删除这个
@pytest.fixture(scope="session")
def event_loop():
    import asyncio
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    yield loop
    loop.close()

pytest-asyncio 完全自动管理事件循环。

步骤 3:移除 loop_scope="function" 标记

检查测试文件,移除这样的标记:

python 复制代码
# ❌ 修改前
@pytest.mark.asyncio(loop_scope="function")
class TestMyAPI:
    async def test_something(self):
        ...

# ✅ 修改后
@pytest.mark.asyncio
class TestMyAPI:
    async def test_something(self):
        ...

完整示例

conftest.py

python 复制代码
"""测试配置 - session 级别事件循环策略。

pytest-asyncio 配置为 session 级别事件循环(见 pyproject.toml):
  - asyncio_mode = "auto"
  - asyncio_default_fixture_loop_scope = "session"
  - asyncio_default_test_loop_scope = "session"

这确保整个测试 session 共享同一个事件循环,避免跨循环的 Future 冲突。
"""

import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool

TEST_DB_URL = "postgresql+asyncpg://user:pass@localhost/test_db"


@pytest_asyncio.fixture(scope="session")
async def pg_engine():
    """创建测试数据库引擎(session scope)。

    注意:此 fixture 在 session 级别的事件循环中初始化。
    所有测试必须使用同一个循环(通过 pyproject.toml 配置)。
    """
    from sqlalchemy.pool import NullPool

    engine = create_async_engine(
        TEST_DB_URL,
        echo=False,
        poolclass=NullPool,  # 禁用连接池,每个请求创建新连接
    )
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(pg_engine):
    """每个测试使用独立事务,测试结束后回滚。"""
    session_factory = async_sessionmaker(pg_engine, expire_on_commit=False)

    async with session_factory() as session:
        await session.begin()
        yield session
        await session.rollback()


@pytest_asyncio.fixture
async def http_client(db_session):
    """HTTP 客户端(function scope)。

    虽然是 function scope,但由于配置了 session 级别测试循环,
    所有测试使用同一个事件循环,因此不会出现跨循环冲突。
    """
    from httpx import AsyncClient, ASGITransport
    from myapp.main import app

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        yield client

test_example.py

python 复制代码
"""API 测试示例。"""

import pytest


@pytest.mark.asyncio
class TestUserAPI:
    """测试用户 API。

    使用 session 级别事件循环,避免跨循环的 Future 冲突。
    """

    async def test_create_user(self, http_client):
        """测试创建用户。"""
        response = await http_client.post(
            "/api/users",
            json={"name": "Alice", "email": "alice@example.com"},
        )
        assert response.status_code == 200

    async def test_get_user(self, http_client):
        """测试获取用户。"""
        response = await http_client.get("/api/users/1")
        assert response.status_code == 200

方案原理解析

修复后的架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    pytest session                           │
├─────────────────────────────────────────────────────────────┤
│  pytest-asyncio 根据 asyncio_default_test_loop_scope="session"│
│       └── 创建 Loop S(整个 session 唯一的循环)             │
│            └── pg_engine (session scope) 在 Loop S 中初始化  │
│                 └── 所有连接持有 Loop S 的 Future            │
├─────────────────────────────────────────────────────────────┤
│  所有测试方法都运行在 Loop S 中                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │ test_method_1│  │ test_method_2│  │ test_method_3│      │
│  │   (Loop S)   │  │   (Loop S)   │  │   (Loop S)   │      │
│  └──────────────┘  └──────────────┘  └──────────────┘      │
│        ✓                  ✓                  ✓              │
└─────────────────────────────────────────────────────────────┘

配置项说明

配置项 作用 默认值 推荐值
asyncio_mode 自动识别 async def 测试 "auto" "auto"
asyncio_default_fixture_loop_scope fixture 使用的默认循环级别 "function" "session"
asyncio_default_test_loop_scope 测试方法使用的默认循环级别 "function" "session"

常见问题

Q1: 为什么要用 session 级别的循环?

A: 因为数据库 engine 通常是 session scope 的。如果每个测试使用新循环,engine 的连接就会持有旧循环的 Future,导致冲突。

Q2: 这会影响测试隔离性吗?

A : 不会。测试隔离性由数据库事务回滚保证,不是由事件循环隔离保证。每个测试仍然有独立的 db_session 和事务。

Q3: NullPool 是必须的吗?

A: 不是必须的,但推荐。NullPool 禁用连接池,每个请求创建新连接。配合 session 级别循环,可以进一步减少连接复用问题。

Q4: 我的项目用 Redis/MQTT 也会遇到吗?

A: 是的。任何在 session 级别 fixture 中创建的异步对象(连接池、客户端等),都会绑定到创建时的循环。如果测试使用不同循环,就会冲突。解决方案相同。


迁移清单

从有问题的配置迁移到正确配置:

  • pyproject.toml 中添加 asyncio_default_fixture_loop_scope = "session"
  • pyproject.toml 中添加 asyncio_default_test_loop_scope = "session"
  • 删除手动的 event_loop fixture(如果存在)
  • 移除所有 @pytest.mark.asyncio(loop_scope="function") 标记
  • 确认 pg_engine 或类似 fixture 是 scope="session"
  • 确认 db_session 或类似 fixture 是 function scope(默认)
  • 运行完整测试套件验证

总结

这个问题的本质是 asyncio 对象与事件循环的生命周期绑定 。解决方案的核心是 统一事件循环的生命周期

  1. 让 pytest-asyncio 自动管理循环
  2. 配置所有组件使用 session 级别循环
  3. 移除手动干预和 function 级别的循环标记

这是一个通用模式,适用于任何使用异步数据库、缓存、消息队列的 pytest-asyncio 测试套件。


后记

如果你现在正在用AI解决这个问题,请把这个Fastapi的官方链接提供给AI,能大幅提高解决此问题的效率:https://fastapi.tiangolo.com/zh/advanced/async-tests/#in-detail

参考

相关推荐
2401_827499994 小时前
python项目实战10-网络机器人03
开发语言·python·php
小江的记录本4 小时前
【Transformer架构】Transformer架构核心知识体系(包括自注意力机制、多头注意力、Encoder-Decoder结构)
java·人工智能·后端·python·深度学习·架构·transformer
7年前端辞职转AI4 小时前
Python 注释
python·编程语言
xcjbqd04 小时前
CSS如何给Bootstrap侧边菜单加图标_使用font-awesome结合CSS
jvm·数据库·python
坐吃山猪5 小时前
Python09_正则表达式
开发语言·python·正则表达式
deephub5 小时前
从检索到回答:RAG 流水线中三个被忽视的故障点
人工智能·python·大语言模型·向量检索·rag
yiruwanlu5 小时前
乡村文旅设计师推荐:建筑设计能力筛选要点解析
python·ui
站大爷IP5 小时前
Python 操作 Word 页眉页脚完整指南
python
SomeB1oody5 小时前
【Python深度学习】2.1. 卷积神经网络(CNN)模型理论(基础):卷积运算、池化、ReLU函数
开发语言·人工智能·python·深度学习·机器学习·cnn