Pytest测试Typer CLI仅首个用例通过 报Event loop is closed错误
运行环境
- pytest==7.1.2
- typer==0.4.1
问题现象
测试文件代码如下:
import pytest from mimicbot import ( __app_name__, cli, config, ) import typer from typer.testing import CliRunner, Result from configparser import ConfigParser from pathlib import Path default_path = Path(config.APP_DIR_PATH) default_config = default_path / "config.ini" @pytest.fixture def mock_config_path(tmp_path): tmp_config_path = tmp_path / "config.ini" config.init_app(tmp_path) # copy a file from path default_config and paste it to tmp_config_path tmp_config_path.write_text(default_config.read_text()) return tmp_config_path class TestMine: # Must have an initialized mimicbot config to the default parameters def test_successful_mine(self, mock_config_path, tmp_path): parsed_config = ConfigParser() parsed_config.read(str(mock_config_path)) runner = CliRunner() result = runner.invoke(cli.app, ["mine", "-ap", tmp_path]) data_path = tmp_path / "data" / parsed_config.get("discord", "guild") / parsed_config.get("general", "session") messages_path = data_path / "messages.csv" assert "Successfully mined data." in result.stdout assert result.exit_code == 0 assert messages_path.exists() def test_failed_mine_api_key(self, mock_config_path, tmp_path): parsed_config = ConfigParser() parsed_config.read(str(mock_config_path)) parsed_config.set("discord", "api_key", "xxFAKExx") with open(str(mock_config_path), "w") as config_file: parsed_config.write(config_file) runner = CliRunner() result = runner.invoke(cli.app, ["mine", "-ap", tmp_path]) assert "Error: API_KEY_ERROR" in result.stdout assert result.exit_code == 1
两个测试用例均依赖CLI执行完成,CLI底层调用的函数单次运行需要数秒。测试过程中无论如何调整两个用例的执行顺序,永远只有顺序第一的用例可以通过,后续执行的用例均失败:
- 按默认顺序执行时,第二个测试用例
test_failed_mine_api_key触发断言失败:预期输出的Error: API_KEY_ERROR字符串不在空的result.stdout中,实际返回结果为<Result RuntimeError('Event loop is closed')>,同时伴随三类警告:- discord客户端调用
asyncio.get_event_loop()的弃用警告 - Windows系统下Proactor管道传输对象析构时触发的事件循环已关闭的未捕获异常警告
- discord客户端运行协程未被等待的运行时警告
- discord客户端调用
- 调换两个测试用例的执行顺序后,原本可正常通过的
test_successful_mine作为第二个执行的用例,触发完全相同的Event loop is closed报错导致断言失败。将两个测试拆分到不同文件中,问题仍然复现。
根因分析
核心问题是跨测试用例的asyncio事件循环状态污染:
- CLI底层依赖的discord客户端为全局初始化实例,第一次运行时会自动绑定当前进程的默认asyncio事件循环。discord客户端的
run()方法执行结束后,会主动关闭绑定的事件循环,但全局的客户端实例引用、残留的事件循环指针没有被销毁重置。 - 后续测试执行时,discord客户端的初始化逻辑会尝试复用已经被关闭的旧事件循环,直接抛出
RuntimeError('Event loop is closed'),导致CLI逻辑根本没有正常执行,stdout为空。 - Windows平台默认使用的
ProactorEventLoop本身存在管道资源析构的已知bug,事件循环关闭后未正确释放的管道资源会额外抛出未捕获异常,进一步放大问题表现。 - Typer的
CliRunner为同步调用工具,不会自动处理asyncio相关的资源清理和状态重置,因此无法自动规避这类全局状态污染问题。
修复方案
按优先级从高到低可选择以下方案:
- 添加自动重置事件循环的pytest fixture
在测试目录的conftest.py中添加如下自动执行的fixture,每个测试用例执行前都创建全新的事件循环,同时重置全局discord客户端实例,从根源隔离测试间的状态污染:import asyncio import pytest from mimicbot import cli @pytest.fixture(autouse=True) def reset_async_env(): # 新建干净的事件循环设为全局默认 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) # 重置全局discord客户端实例,避免复用旧实例的残留状态 if hasattr(cli, "discord_client"): cli.discord_client = None yield # 用例执行完关闭循环,释放资源 loop.close() - 修复Windows平台事件循环兼容性问题
同样在conftest.py中添加如下配置,替换Windows下默认的Proactor事件循环为Selector事件循环,规避管道析构的已知bug:import sys import asyncio if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - 优化业务侧discord客户端的启动逻辑
修改CLI中启动discord客户端的代码,不要直接依赖全局默认事件循环,每次调用时显式创建新的事件循环传入,客户端运行结束后显式调用close()方法释放资源,不要保留全局的客户端实例引用,从业务代码层面避免状态残留。
注意不要直接使用无参数的client.run(),该方法会自动关闭传入/默认的事件循环,若需要复用循环可手动拆分client.start()和事件循环的启动/关闭逻辑。
内容的提问来源于stack exchange,提问作者Sebastian Sosa
相关产品推荐
相关产品推荐

