nonebot2/website/versioned_docs/version-2.3.3/best-practice/testing
2024-11-23 12:29:12 +08:00
..
_category_.json 🔖 Release 2.3.3 2024-08-18 03:57:17 +00:00
behavior.mdx 🔖 Release 2.3.3 2024-08-18 03:57:17 +00:00
mock-network.md 🔖 Release 2.3.3 2024-08-18 03:57:17 +00:00
README.mdx 📝 Docs: 添加 pytest-asyncio 配置 (#3136) 2024-11-23 12:29:12 +08:00

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
sidebar_position: 1
description: 使用 NoneBug 进行单元测试

slug: /best-practice/testing/
---

# 配置与测试事件响应器

import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";

> 在计算机编程中单元测试Unit Testing又称为模块测试是针对程序模块软件设计的最小单位来进行正确性检验的测试工作。

为了保证代码的正确运行我们不仅需要对错误进行跟踪还需要对代码进行正确性检验也就是测试。NoneBot 提供了一个测试工具——NoneBug它是一个 [pytest](https://docs.pytest.org/en/stable/) 插件,可以帮助我们便捷地进行单元测试。

:::tip 提示
建议在阅读本文档前先阅读 [pytest 官方文档](https://docs.pytest.org/en/stable/)来了解 pytest 的相关术语和基本用法。
:::

## 安装 NoneBug

在**项目目录**下激活虚拟环境后运行以下命令安装 NoneBug

<Tabs groupId="tool">
  <TabItem value="poetry" label="Poetry" default>

```bash
poetry add nonebug -G test
```

  </TabItem>
  <TabItem value="pdm" label="PDM">

```bash
pdm add nonebug -dG test
```

  </TabItem>
  <TabItem value="pip" label="pip">

```bash
pip install nonebug
```

  </TabItem>
</Tabs>

要运行 NoneBug 测试,还需要额外安装 pytest 异步插件 `pytest-asyncio` 或 `anyio` 以支持异步测试。文档中,我们以 `pytest-asyncio` 为例:

<Tabs groupId="tool">
  <TabItem value="poetry" label="Poetry" default>

```bash
poetry add pytest-asyncio -G test
```

  </TabItem>
  <TabItem value="pdm" label="PDM">

```bash
pdm add pytest-asyncio -dG test
```

  </TabItem>
  <TabItem value="pip" label="pip">

```bash
pip install pytest-asyncio
```

  </TabItem>
</Tabs>

## 配置测试

在开始测试之前,我们需要对测试进行一些配置,以正确启动我们的机器人。

首先我们需要配置 pytest-asyncio在 `pyproject.toml` 的 pytest 配置部分添加:

```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
```

然后,我们在 `tests` 目录下新建 `conftest.py` 文件,添加以下内容:

```python title=tests/conftest.py
import pytest
import nonebot
from pytest_asyncio import is_async_test
# 导入适配器
from nonebot.adapters.console import Adapter as ConsoleAdapter

def pytest_collection_modifyitems(items: list[pytest.Item]):
    pytest_asyncio_tests = (item for item in items if is_async_test(item))
    session_scope_marker = pytest.mark.asyncio(loop_scope="session")
    for async_test in pytest_asyncio_tests:
        async_test.add_marker(session_scope_marker, append=False)

@pytest.fixture(scope="session", autouse=True)
async def after_nonebot_init(after_nonebot_init: None):
    # 加载适配器
    driver = nonebot.get_driver()
    driver.register_adapter(ConsoleAdapter)

    # 加载插件
    nonebot.load_from_toml("pyproject.toml")
```

这样,我们就可以在测试中使用机器人的插件了。通常,我们不需要自行初始化 NoneBotNoneBug 已经为我们运行了 `nonebot.init()`。如果需要自定义 NoneBot 初始化的参数,我们可以在 `conftest.py` 中添加 `pytest_configure` 钩子函数。例如,我们可以修改 NoneBot 配置环境为 `test` 并从环境变量中输入配置:

```python {4,6,8-10} title=tests/conftest.py
import os

import pytest
from nonebug import NONEBOT_INIT_KWARGS

os.environ["ENVIRONMENT"] = "test"

def pytest_configure(config: pytest.Config):
    config.stash[NONEBOT_INIT_KWARGS] = {"secret": os.getenv("INPUT_SECRET")}
```

NoneBug 默认也会为我们管理 lifespan 的 startup 与 shutdown。如果不希望 NoneBug 管理 lifespan你可以在 `pytest_configure` 里添加以下配置:

```python
import pytest
from nonebug import NONEBOT_START_LIFESPAN

def pytest_configure(config: pytest.Config):
    config.stash[NONEBOT_START_LIFESPAN] = False
```

## 编写插件测试

在配置完成插件加载后我们就可以在测试中使用插件了。NoneBug 通过 pytest fixture `app` 提供各种测试方法,我们可以在测试中使用它来测试插件。现在,我们创建一个测试脚本来测试[深入指南](../../appendices/session-control.mdx)中编写的天气插件。首先,我们先要导入我们需要的模块:

<details>
  <summary>插件示例</summary>

```python title=weather/__init__.py
from nonebot import on_command
from nonebot.rule import to_me
from nonebot.matcher import Matcher
from nonebot.adapters import Message
from nonebot.params import CommandArg, ArgPlainText

weather = on_command("天气", rule=to_me(), aliases={"weather", "天气预报"})

@weather.handle()
async def handle_function(matcher: Matcher, args: Message = CommandArg()):
    if args.extract_plain_text():
        matcher.set_arg("location", args)

@weather.got("location", prompt="请输入地名")
async def got_location(location: str = ArgPlainText()):
    if location not in ["北京", "上海", "广州", "深圳"]:
        await weather.reject(f"你想查询的城市 {location} 暂不支持,请重新输入!")
    await weather.finish(f"今天{location}的天气是...")
```

</details>

```python {4,5,9,11-16} title=tests/test_weather.py
from datetime import datetime

import pytest
from nonebug import App
from nonebot.adapters.console import User, Message, MessageEvent

@pytest.mark.asyncio
async def test_weather(app: App):
    from awesome_bot.plugins.weather import weather

    event = MessageEvent(
        time=datetime.now(),
        self_id="test",
        message=Message("/天气 北京"),
        user=User(id="user"),
    )
```

在上面的代码中,我们引入了 NoneBug 的测试 `App` 对象,以及必要的适配器消息与事件定义等。在测试函数 `test_weather` 中,我们导入了要进行测试的事件响应器 `weather`。请注意,由于需要等待 NoneBot 初始化并加载插件完毕,插件内容必须在**测试函数内部**进行导入。然后,我们创建了一个 `MessageEvent` 事件对象,它模拟了一个用户发送了 `/天气 北京` 的消息。接下来,我们使用 `app.test_matcher` 方法来测试 `weather` 事件响应器:

```python {11-15} title=tests/test_weather.py
@pytest.mark.asyncio
async def test_weather(app: App):
    from awesome_bot.plugins.weather import weather

    event = MessageEvent(
        time=datetime.now(),
        self_id="test",
        message=Message("/天气 北京"),
        user=User(id="user"),
    )
    async with app.test_matcher(weather) as ctx:
        bot = ctx.create_bot()
        ctx.receive_event(bot, event)
        ctx.should_call_send(event, "今天北京的天气是...", result=None)
        ctx.should_finished(weather)
```

这里我们使用 `async with` 语句并通过参数指定要测试的事件响应器 `weather` 来进入测试上下文。在测试上下文中,我们可以使用 `ctx.create_bot` 方法创建一个虚拟的机器人实例,并使用 `ctx.receive_event` 方法来模拟机器人接收到消息事件。然后,我们就可以定义预期行为来测试机器人是否正确运行。在上面的代码中,我们使用 `ctx.should_call_send` 方法来断言机器人应该发送 `今天北京的天气是...` 这条消息,并且将发送函数的调用结果作为第三个参数返回给事件处理函数。如果断言失败,测试将会不通过。我们也可以使用 `ctx.should_finished` 方法来断言机器人应该结束会话。

为了测试更复杂的情况,我们可以为添加更多的测试用例。例如,我们可以测试用户输入了一个不支持的地名时机器人的反应:

```python {17-21,23-26} title=tests/test_weather.py
def make_event(message: str = "") -> MessageEvent:
    return MessageEvent(
        time=datetime.now(),
        self_id="test",
        message=Message(message),
        user=User(id="user"),
    )

@pytest.mark.asyncio
async def test_weather(app: App):
    from awesome_bot.plugins.weather import weather

    async with app.test_matcher(weather) as ctx:
        ...  # 省略前面的测试用例

    async with app.test_matcher(weather) as ctx:
        bot = ctx.create_bot()
        event = make_event("/天气 南京")
        ctx.receive_event(bot, event)
        ctx.should_call_send(event, "你想查询的城市 南京 暂不支持,请重新输入!", result=None)
        ctx.should_rejected(weather)

        event = make_event("北京")
        ctx.receive_event(bot, event)
        ctx.should_call_send(event, "今天北京的天气是...", result=None)
        ctx.should_finished(weather)
```

在上面的代码中,我们使用 `ctx.should_rejected` 来断言机器人应该请求用户重新输入。然后,我们再次使用 `ctx.receive_event` 方法来模拟用户回复了 `北京`,并使用 `ctx.should_finished` 来断言机器人应该结束会话。

更多的 NoneBug 用法将在后续章节中介绍。