🔖 Release 2.4.0

This commit is contained in:
noneflow[bot]
2024-10-31 13:45:33 +00:00
parent a50a3398de
commit 5e86d53e0b
97 changed files with 19568 additions and 2 deletions

View File

@ -0,0 +1,233 @@
---
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 nonebot.adapters.console import Adapter as ConsoleAdapter
@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 用法将在后续章节中介绍。

View File

@ -0,0 +1,4 @@
{
"label": "单元测试",
"position": 5
}

View File

@ -0,0 +1,292 @@
---
sidebar_position: 2
description: 测试事件响应、平台接口调用和会话控制
---
# 测试事件响应与会话操作
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
在 NoneBot 接收到事件时,事件响应器根据优先级依次通过权限、响应规则来判断当前事件是否应该触发。事件响应流程中,机器人可能会通过 `send` 发送消息或者调用平台接口来执行预期的操作。因此,我们需要对这两种操作进行单元测试。
在上一节中我们对单个事件响应器进行了简单测试。但是在实际场景中机器人可能定义了多个事件响应器由于优先级和响应规则的存在预期的事件响应器可能并不会被触发。NoneBug 支持同时测试多个事件响应器,以此来测试机器人的整体行为。
## 测试事件响应
NoneBug 提供了六种定义 `Rule` 和 `Permission` 预期行为的方法:
- `should_pass_rule`
- `should_not_pass_rule`
- `should_ignore_rule`
- `should_pass_permission`
- `should_not_pass_permission`
- `should_ignore_permission`
:::tip 提示
事件响应器类型的检查属于 `Permission` 的一部分,因此可以通过 `should_pass_permission` 和 `should_not_pass_permission` 方法来断言事件响应器类型的检查。
:::
下面我们根据插件示例来测试事件响应行为,我们首先定义两个事件响应器作为测试的对象:
```python title=example.py
from nonebot import on_command
def never_pass():
return False
foo = on_command("foo")
bar = on_command("bar", permission=never_pass)
```
在这两个事件响应器中,`foo` 当收到 `/foo` 消息时会执行,而 `bar` 则不会执行。我们使用 NoneBug 来测试它们:
<Tabs groupId="testScope">
<TabItem value="separate" label="独立测试" default>
```python {21,22,28,29} title=tests/test_example.py
from datetime import datetime
import pytest
from nonebug import App
from nonebot.adapters.console import User, Message, MessageEvent
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_example(app: App):
from awesome_bot.plugins.example import foo, bar
async with app.test_matcher(foo) as ctx:
bot = ctx.create_bot()
event = make_event("/foo")
ctx.receive_event(bot, event)
ctx.should_pass_rule()
ctx.should_pass_permission()
async with app.test_matcher(bar) as ctx:
bot = ctx.create_bot()
event = make_event("/foo")
ctx.receive_event(bot, event)
ctx.should_not_pass_rule()
ctx.should_not_pass_permission()
```
在上面的代码中,我们分别对 `foo` 和 `bar` 事件响应器进行响应测试。我们使用 `ctx.should_pass_rule` 和 `ctx.should_pass_permission` 断言 `foo` 事件响应器应该被触发,使用 `ctx.should_not_pass_rule` 和 `ctx.should_not_pass_permission` 断言 `bar` 事件响应器应该被忽略。
</TabItem>
<TabItem value="global" label="集成测试">
```python title=tests/test_example.py
from datetime import datetime
import pytest
from nonebug import App
from nonebot.adapters.console import User, Message, MessageEvent
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_example(app: App):
from awesome_bot.plugins.example import foo, bar
async with app.test_matcher() as ctx:
bot = ctx.create_bot()
event = make_event("/foo")
ctx.receive_event(bot, event)
ctx.should_pass_rule(foo)
ctx.should_pass_permission(foo)
ctx.should_not_pass_rule(bar)
ctx.should_not_pass_permission(bar)
```
在上面的代码中,我们对 `foo` 和 `bar` 事件响应器一起进行响应测试。我们使用 `ctx.should_pass_rule` 和 `ctx.should_pass_permission` 断言 `foo` 事件响应器应该被触发,使用 `ctx.should_not_pass_rule` 和 `ctx.should_not_pass_permission` 断言 `bar` 事件响应器应该被忽略。通过参数,我们可以指定断言的事件响应器。
</TabItem>
</Tabs>
当然,如果需要忽略某个事件响应器的响应规则和权限检查,强行进入响应流程,我们可以使用 `should_ignore_rule` 和 `should_ignore_permission` 方法:
```python {21,22} title=tests/test_example.py
from datetime import datetime
import pytest
from nonebug import App
from nonebot.adapters.console import User, Message, MessageEvent
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_example(app: App):
from awesome_bot.plugins.example import foo, bar
async with app.test_matcher(bar) as ctx:
bot = ctx.create_bot()
event = make_event("/foo")
ctx.receive_event(bot, event)
ctx.should_ignore_rule(bar)
ctx.should_ignore_permission(bar)
```
在忽略了响应规则和权限检查之后,就会进入 `bar` 事件响应器的响应流程。
## 测试平台接口使用
上一节的示例插件测试中,我们已经尝试了测试插件对事件的消息回复。通常情况下,事件处理流程中对平台接口的使用会通过事件响应器操作或者调用平台 API 两种途径进行。针对这两种途径NoneBug 分别提供了 `ctx.should_call_send` 和 `ctx.should_call_api` 方法来测试平台接口的使用情况。
1. `should_call_send`
定义事件响应器预期发送的消息,即通过[事件响应器操作 send](../../appendices/session-control.mdx#send)进行的操作。`should_call_send` 有四个参数:
- `event`:回复的目标事件。
- `message`:预期的消息对象,可以是 `str`、`Message` 或 `MessageSegment`。
- `result`send 的返回值,将会返回给插件。
- `bot`(可选):发送消息的 bot 对象。
- `**kwargs`send 方法的额外参数。
2. `should_call_api`
定义事件响应器预期调用的平台 API 接口,即通过[调用平台 API](../../appendices/api-calling.mdx#调用平台-API)进行的操作。`should_call_api` 有四个参数:
- `api`API 名称。
- `data`:预期的请求数据。
- `result`call_api 的返回值,将会返回给插件。
- `adapter`(可选):调用 API 的平台适配器对象。
- `**kwargs`call_api 方法的额外参数。
下面是一个使用 `should_call_send` 和 `should_call_api` 方法的示例:
我们先定义一个测试插件,在响应流程中向用户发送一条消息并调用 `Console` 适配器的 `bell` API。
```python {8,9} title=example.py
from nonebot import on_command
from nonebot.adapters.console import Bot
foo = on_command("foo")
@foo.handle()
async def _(bot: Bot):
await foo.send("message")
await bot.bell()
```
然后我们对该插件进行测试:
```python title=tests/test_example.py
from datetime import datetime
import pytest
import nonebot
from nonebug import App
from nonebot.adapters.console import Bot, User, Adapter, Message, MessageEvent
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_example(app: App):
from awesome_bot.plugins.example import foo
async with app.test_matcher(foo) as ctx:
# highlight-start
adapter = nonebot.get_adapter(Adapter)
bot = ctx.create_bot(base=Bot, adapter=adapter)
# highlight-end
event = make_event("/foo")
ctx.receive_event(bot, event)
# highlight-start
ctx.should_call_send(event, "message", result=None, bot=bot)
ctx.should_call_api("bell", {}, result=None, adapter=adapter)
# highlight-end
```
请注意,对于在依赖注入中使用了非基类对象的情况,我们需要在 `create_bot` 方法中指定 `base` 和 `adapter` 参数,确保不会因为重载功能而出现非预期情况。
## 测试会话控制
在[会话控制](../../appendices/session-control.mdx)一节中,我们介绍了如何使用事件响应器操作来实现对用户的交互式会话。在上一节的示例插件测试中,我们其实已经使用了 `ctx.should_finished` 来断言会话结束。NoneBug 针对各种流程控制操作分别提供了相应的方法来定义预期的会话处理行为。它们分别是:
- `should_finished`:断言会话结束,对应 `matcher.finish` 操作。
- `should_rejected`:断言会话等待用户输入并重新执行当前事件处理函数,对应 `matcher.reject` 系列操作。
- `should_paused`: 断言会话等待用户输入并执行下一个事件处理函数,对应 `matcher.pause` 操作。
我们仅需在测试用例中的正确位置调用这些方法,就可以断言会话的预期行为。例如:
```python title=example.py
from nonebot import on_command
from nonebot.typing import T_State
foo = on_command("foo")
@foo.got("key", prompt="请输入密码")
async def _(state: T_State, key: str = ArgPlainText()):
if key != "some password":
try_count = state.get("try_count", 1)
if try_count >= 3:
await foo.finish("密码错误次数过多")
else:
state["try_count"] = try_count + 1
await foo.reject("密码错误,请重新输入")
await foo.finish("密码正确")
```
```python title=tests/test_example.py
from datetime import datetime
import pytest
from nonebug import App
from nonebot.adapters.console import User, Message, MessageEvent
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_example(app: App):
from awesome_bot.plugins.example import foo
async with app.test_matcher(foo) as ctx:
bot = ctx.create_bot()
event = make_event("/foo")
ctx.receive_event(bot, event)
ctx.should_call_send(event, "请输入密码", result=None)
ctx.should_rejected(foo)
event = make_event("wrong password")
ctx.receive_event(bot, event)
ctx.should_call_send(event, "密码错误,请重新输入", result=None)
ctx.should_rejected(foo)
event = make_event("wrong password")
ctx.receive_event(bot, event)
ctx.should_call_send(event, "密码错误,请重新输入", result=None)
ctx.should_rejected(foo)
event = make_event("wrong password")
ctx.receive_event(bot, event)
ctx.should_call_send(event, "密码错误次数过多", result=None)
ctx.should_finished(foo)
```

View File

@ -0,0 +1,96 @@
---
sidebar_position: 3
description: 模拟网络通信以进行测试
---
# 模拟网络通信
NoneBot 驱动器提供了多种方法来帮助适配器进行网络通信,主要包括客户端和服务端两种类型。模拟网络通信可以帮助我们更加接近实际机器人应用场景,进行更加真实的集成测试。同时,通过这种途径,我们还可以完成对适配器的测试。
NoneBot 中的网络通信主要包括以下几种:
- HTTP 服务端WebHook
- WebSocket 服务端
- HTTP 客户端
- WebSocket 客户端
下面我们将分别介绍如何使用 NoneBug 来模拟这几种通信方式。
## 测试 HTTP 服务端
当 NoneBot 作为 ASGI 服务端应用时,我们可以定义一系列的路由来处理 HTTP 请求,适配器同样也可以通过定义路由来响应机器人相关的网络通信。下面假设我们使用了一个适配器 `fake` ,它定义了一个路由 `/fake/http` ,用于接收平台 WebHook 并处理。实际应用测试时,应将该路由地址替换为**真实适配器注册的路由地址**。
我们首先需要获取测试用模拟客户端:
```python {5,6} title=tests/test_http_server.py
from nonebug import App
@pytest.mark.asyncio
async def test_http_server(app: App):
async with app.test_server() as ctx:
client = ctx.get_client()
```
默认情况下,`app.test_server()` 会通过 `nonebot.get_asgi` 获取测试对象,我们也可以通过参数指定 ASGI 应用:
```python
async with app.test_server(asgi=asgi_app) as ctx:
...
```
获取到模拟客户端后,即可像 `requests`、`httpx` 等库类似的方法进行使用:
```python {3,11-14,16} title=tests/test_http_server.py
import nonebot
from nonebug import App
from nonebot.adapters.fake import Adapter
@pytest.mark.asyncio
async def test_http_server(app: App):
adapter = nonebot.get_adapter(Adapter)
async with app.test_server() as ctx:
client = ctx.get_client()
response = await client.post("/fake/http", json={"bot_id": "fake"})
assert response.status_code == 200
assert response.json() == {"status": "success"}
assert "fake" in nonebot.get_bots()
adapter.bot_disconnect(nonebot.get_bot("fake"))
```
在上面的测试中,我们向 `/fake/http` 发送了一个模拟 POST 请求适配器将会对该请求进行处理我们可以通过检查请求返回是否正确、Bot 对象是否创建等途径来验证机器人是否正确运行。在完成测试后,我们通常需要对 Bot 对象进行清理,以避免对其他测试产生影响。
## 测试 WebSocket 服务端
当 NoneBot 作为 ASGI 服务端应用时,我们还可以定义一系列的路由来处理 WebSocket 通信。下面假设我们使用了一个适配器 `fake` ,它定义了一个路由 `/fake/ws` ,用于处理平台 WebSocket 连接信息。实际应用测试时,应将该路由地址替换为**真实适配器注册的路由地址**。
我们同样需要通过 `app.test_server()` 获取测试用模拟客户端,这里就不再赘述。在获取到模拟客户端后,我们可以通过 `client.websocket_connect` 方法来模拟 WebSocket 连接:
```python {3,11-15} title=tests/test_ws_server.py
import nonebot
from nonebug import App
from nonebot.adapters.fake import Adapter
@pytest.mark.asyncio
async def test_ws_server(app: App):
adapter = nonebot.get_adapter(Adapter)
async with app.test_server() as ctx:
client = ctx.get_client()
async with client.websocket_connect("/fake/ws") as ws:
await ws.send_json({"bot_id": "fake"})
response = await ws.receive_json()
assert response == {"status": "success"}
assert "fake" in nonebot.get_bots()
```
在上面的测试中,我们向 `/fake/ws` 进行了 WebSocket 模拟通信,通过发送消息与机器人进行交互,然后检查机器人发送的信息是否正确。
## 测试 HTTP 客户端
~~暂不支持~~
## 测试 WebSocket 客户端
~~暂不支持~~