当你使用 pytest-asyncio 测试 FastAPI/SQLAlchemy 应用时,可能会遇到这样的错误:
Task got Future <Future pending cb=[BaseProtocol._on_waiter_completed()]> attached to a different loop
这是一个经典且令人困惑 的问题。本文将深入分析其根本原因,并提供一个通用的解决方案。
问题现象
复现条件
以下条件同时满足时,问题会出现:
- 使用
pytest-asyncio运行异步测试 - 使用 SQLAlchemy + asyncpg(或任何异步数据库驱动)
- 数据库 engine 是 session 级别的 fixture
- 测试方法使用了
@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_loopfixture(如果存在) - 移除所有
@pytest.mark.asyncio(loop_scope="function")标记 - 确认
pg_engine或类似 fixture 是scope="session" - 确认
db_session或类似 fixture 是 function scope(默认) - 运行完整测试套件验证
总结
这个问题的本质是 asyncio 对象与事件循环的生命周期绑定 。解决方案的核心是 统一事件循环的生命周期:
- 让 pytest-asyncio 自动管理循环
- 配置所有组件使用 session 级别循环
- 移除手动干预和 function 级别的循环标记
这是一个通用模式,适用于任何使用异步数据库、缓存、消息队列的 pytest-asyncio 测试套件。
后记
如果你现在正在用AI解决这个问题,请把这个Fastapi的官方链接提供给AI,能大幅提高解决此问题的效率:https://fastapi.tiangolo.com/zh/advanced/async-tests/#in-detail