You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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客户端运行协程未被等待的运行时警告
  • 调换两个测试用例的执行顺序后,原本可正常通过的test_successful_mine作为第二个执行的用例,触发完全相同的Event loop is closed报错导致断言失败。将两个测试拆分到不同文件中,问题仍然复现。
根因分析

核心问题是跨测试用例的asyncio事件循环状态污染:

  1. CLI底层依赖的discord客户端为全局初始化实例,第一次运行时会自动绑定当前进程的默认asyncio事件循环。discord客户端的run()方法执行结束后,会主动关闭绑定的事件循环,但全局的客户端实例引用、残留的事件循环指针没有被销毁重置。
  2. 后续测试执行时,discord客户端的初始化逻辑会尝试复用已经被关闭的旧事件循环,直接抛出RuntimeError('Event loop is closed'),导致CLI逻辑根本没有正常执行,stdout为空。
  3. Windows平台默认使用的ProactorEventLoop本身存在管道资源析构的已知bug,事件循环关闭后未正确释放的管道资源会额外抛出未捕获异常,进一步放大问题表现。
  4. Typer的CliRunner为同步调用工具,不会自动处理asyncio相关的资源清理和状态重置,因此无法自动规避这类全局状态污染问题。
修复方案

按优先级从高到低可选择以下方案:

  1. 添加自动重置事件循环的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()
    
  2. 修复Windows平台事件循环兼容性问题
    同样在conftest.py中添加如下配置,替换Windows下默认的Proactor事件循环为Selector事件循环,规避管道析构的已知bug:
    import sys
    import asyncio
    
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
    
  3. 优化业务侧discord客户端的启动逻辑
    修改CLI中启动discord客户端的代码,不要直接依赖全局默认事件循环,每次调用时显式创建新的事件循环传入,客户端运行结束后显式调用close()方法释放资源,不要保留全局的客户端实例引用,从业务代码层面避免状态残留。
    注意不要直接使用无参数的client.run(),该方法会自动关闭传入/默认的事件循环,若需要复用循环可手动拆分client.start()和事件循环的启动/关闭逻辑。

内容的提问来源于stack exchange,提问作者Sebastian Sosa

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 16:06:26