diff --git a/tests/test_examples/test_weather.py b/tests/test_examples/test_weather.py index 086e2c1c..a4432c1b 100644 --- a/tests/test_examples/test_weather.py +++ b/tests/test_examples/test_weather.py @@ -3,7 +3,7 @@ from nonebug import App @pytest.mark.asyncio -async def test_weather(app: App): +async def test_weather(app: App, load_example): from examples.weather import weather from utils import make_fake_event, make_fake_message diff --git a/website/docs/advanced/unittest.md b/website/docs/advanced/unittest.md deleted file mode 100644 index f480a2d7..00000000 --- a/website/docs/advanced/unittest.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: 编写单元测试 - -options: - menu: - weight: 80 - category: advanced ---- - -# 单元测试 diff --git a/website/docs/advanced/unittest/README.mdx b/website/docs/advanced/unittest/README.mdx new file mode 100644 index 00000000..b297f674 --- /dev/null +++ b/website/docs/advanced/unittest/README.mdx @@ -0,0 +1,106 @@ +--- +sidebar_position: 1 +description: 使用 NoneBug 测试机器人 +slug: /advanced/unittest/ + +options: + menu: + weight: 80 + category: advanced +--- + +import CodeBlock from "@theme/CodeBlock"; + +# 单元测试 + +[单元测试](https://zh.wikipedia.org/wiki/%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95) + +> 在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。 + +NoneBot2 使用 [Pytest](https://docs.pytest.org) 单元测试框架搭配 [NoneBug](https://github.com/nonebot/nonebug) 插件进行单元测试,通过直接与事件响应器/适配器等交互简化测试流程,更易于编写。 + +## 安装 NoneBug + +安装 NoneBug 时,Pytest 会作为依赖被一起安装。 + +要运行 NoneBug,还需要额外安装 Pytest 异步插件 `pytest-asyncio` 或 `anyio`,文档将以 `pytest-asyncio` 为例。 + +```bash +poetry add nonebug pytest-asyncio --dev +# 也可以通过 pip 安装 +pip install nonebug pytest-asyncio +``` + +:::tip 提示 +建议首先阅读 [Pytest 文档](https://docs.pytest.org) 理解相关术语。 +::: + +## 加载插件 + +我们可以使用 Pytest **Fixtures** 来加载插件,下面是一个示例: + +```python title=conftest.py +from pathlib import Path +from typing import TYPE_CHECKING, Set + +import pytest + +if TYPE_CHECKING: + from nonebot.plugin import Plugin + + +@pytest.fixture +def load_plugins(nonebug_init: None) -> Set["Plugin"]: + import nonebot # 这里的导入必须在函数内 + + # 加载插件 + return nonebot.load_plugins("awesome_bot/plugins") +``` + +此 Fixture 的 [`nonebug_init`](https://github.com/nonebot/nonebug/blob/master/nonebug/fixture.py) 形参也是一个 Fixture,用于初始化 NoneBug。 + +Fixture 名称 `load_plugins` 可以修改为其他名称,文档以 `load_plugins` 为例。需要加载插件时,在测试函数添加形参 `load_plugins` 即可。加载完成后即可使用 `import` 导入事件响应器。 + +## 测试流程 + +Pytest 会在函数开始前通过 Fixture `app`(nonebug_app) **初始化 NoneBug** 并返回 `App` 对象。 + +:::warning 警告 +所有从 `nonebot` 导入模块的函数都需要首先初始化 NoneBug App,否则会发生不可预料的问题。 + +在每个测试函数结束时,NoneBug 会自动销毁所有与 NoneBot 相关的资源。所有与 NoneBot 相关的 import 应在函数内进行导入。 +::: + +随后使用 `test_matcher` 等测试方法获取到 `Context` 上下文,通过上下文管理提供的方法(如 `should_call_send` 等)预定义行为。 + +在上下文管理器关闭时,`Context` 会调用 `run_test` 方法按照预定义行为对事件响应器进行断言(如:断言事件响应和 API 调用等)。 + +## 测试样例 + +:::tip 提示 +将从 `utils` 导入的 `make_fake_message`,`make_fake_event` 替换为对应平台的消息/事件类型。 + +将 `load_example` 替换为加载插件的 Fixture 名称。 +::: + +使用 NoneBug 的 `test_matcher` 可以模拟出一个事件流程。如下是一个简单的示例: + +import WeatherSource from "!!raw-loader!@site/../tests/examples/weather.py"; +import WeatherTest from "!!raw-loader!@site/../tests/test_examples/test_weather.py"; + + + {WeatherTest} + + +
+ 示例插件 + + {WeatherSource} + +
+ +在测试用例编写完成后 ,可以使用下面的命令运行单元测试。 + +```bash +pytest test_weather.py +``` diff --git a/website/docs/advanced/unittest/_category_.json b/website/docs/advanced/unittest/_category_.json new file mode 100644 index 00000000..502c85d3 --- /dev/null +++ b/website/docs/advanced/unittest/_category_.json @@ -0,0 +1,3 @@ +{ + "label": "单元测试" +} diff --git a/website/docs/advanced/unittest/test-adapters.md b/website/docs/advanced/unittest/test-adapters.md new file mode 100644 index 00000000..04f7c743 --- /dev/null +++ b/website/docs/advanced/unittest/test-adapters.md @@ -0,0 +1,162 @@ +--- +sidebar_position: 4 +description: 测试适配器 +--- + +# 测试适配器 + +通常来说,测试适配器需要测试这三项。 + +1. 测试连接 +2. 测试事件转化 +3. 测试 API 调用 + +## 注册适配器 + +任何的适配器都需要注册才能起作用。 + +我们可以使用 Pytest 的 Fixtures,在测试开始前初始化 NoneBot 并**注册适配器**。 + +我们假设适配器为 `nonebot.adapters.test`。 + +```python {20,21} title=conftest.py +from pathlib import Path + +import pytest +from nonebug import App + +# 如果适配器采用 nonebot.adapters monospace 则需要使用此hook方法正确添加路径 +@pytest.fixture +def import_hook(): + import nonebot.adapters + + nonebot.adapters.__path__.append( # type: ignore + str((Path(__file__).parent.parent / "nonebot" / "adapters").resolve()) + ) + +@pytest.fixture +async def init_adapter(app: App, import_hook): + import nonebot + from nonebot.adapters.test import Adapter + + driver = nonebot.get_driver() + driver.register_adapter(Adapter) +``` + +## 测试连接 + +任何的适配器的连接方式主要有以下 4 种: + +1. 反向 HTTP(WebHook) +2. 反向 WebSocket +3. ~~正向 HTTP(尚未实现)~~ +4. ~~正向 WebSocket(尚未实现)~~ + +NoneBug 的 `test_server` 方法可以供我们测试反向连接方式。 + +`test_server` 的 `get_client` 方法可以获取 HTTP/WebSocket 客户端。 + +我们假设适配器 HTTP 上报地址为 `/test/http`,反向 WebSocket 地址为 `/test/ws`,上报机器人 ID +使用请求头 `Bot-ID` 来演示如何通过 NoneBug 测试适配器。 + +```python {8,16,17,19-22,26,34,36-39} title=test_connection.py +from pathlib import Path + +import pytest +from nonebug import App + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoints", ["/test/http"] +) +async def test_http(app: App, init_adapter, endpoints: str): + import nonebot + + async with app.test_server() as ctx: + client = ctx.get_client() + + body = {"post_type": "test"} + headers = {"Bot-ID": "test"} + + resp = await client.post(endpoints, json=body, headers=headers) + assert resp.status_code == 204 # 检测状态码是否正确 + bots = nonebot.get_bots() + assert "test" in bots # 检测是否连接成功 + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoints", ["/test/ws"] +) +async def test_ws(app: App, init_adapter, endpoints: str): + import nonebot + + async with app.test_server() as ctx: + client = ctx.get_client() + + headers = {"Bot-ID": "test"} + + async with client.websocket_connect(endpoints, headers=headers) as ws: + bots = nonebot.get_bots() + assert "test" in bots # 检测是否连接成功 +``` + +## 测试事件转化 + +事件转化就是将原始数据反序列化为 `Event` 对象的过程。 + +测试事件转化就是测试反序列化是否按照预期转化为对应 `Event` 类型。 + +下面将以 `dict_to_event` 作为反序列化方法,`type` 为 `test` 的事件类型为 `TestEvent` 来演示如何测试事件转化。 + +```python {8,9} title=test_event.py +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_event(app: App, init_adapter): + from nonebot.adapters.test import Adapter, TestEvent + + event = Adapter.dict_to_event({"post_type": "test"}) # 反序列化原始数据 + assert isinstance(event, TestEvent) # 断言类型是否与预期一致 +``` + +## 测试 API 调用 + +将消息序列化为原始数据并由适配器发送到协议端叫做 API 调用。 + +测试 API 调用就是调用 API 并验证返回与预期返回是否一致。 + +```python {16-18,23-32} title=test_call_api.py +import asyncio +from pathlib import Path + +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_ws(app: App, init_adapter): + import nonebot + + async with app.test_server() as ctx: + client = ctx.get_client() + + headers = {"Bot-ID": "test"} + + async def call_api(): + bot = nonebot.get_bot("test") + return await bot.test_api() + + async with client.websocket_connect("/test/ws", headers=headers) as ws: + task = asyncio.create_task(call_api()) + + # received = await ws.receive_text() + # received = await ws.receive_bytes() + received = await ws.receive_json() + assert received == {"action": "test_api"} # 检测调用是否与预期一致 + response = {"result": "test"} + # await ws.send_text(...) + # await ws.send_bytes(...) + await ws.send_json(response, mode="bytes") + result = await task + assert result == response # 检测返回是否与预期一致 +``` diff --git a/website/docs/advanced/unittest/test-matcher-operation.md b/website/docs/advanced/unittest/test-matcher-operation.md new file mode 100644 index 00000000..0452cd10 --- /dev/null +++ b/website/docs/advanced/unittest/test-matcher-operation.md @@ -0,0 +1,122 @@ +--- +sidebar_position: 3 +description: 测试事件响应处理 +--- + +# 测试事件响应处理行为 + +除了 `send`,事件响应器还有其他的操作,我们也需要对它们进行测试,下面我们将定义如下事件响应器操作的预期行为对对应的事件响应器操作进行测试。 + +## should_finished + +定义事件响应器预期结束当前事件的整个处理流程。 + +适用事件响应器操作:[`finish`](../../tutorial/plugin/matcher-operation.md#finish)。 + + +
+ 示例插件 + +```python title=example.py +from nonebot import on_message + +foo = on_message() + +@foo.handle() +async def _(): + await foo.finish("test") +``` + +
+ +```python {13} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_call_send(event, "test", True) + ctx.should_finished() +``` + +## should_paused + +定义事件响应器预期立即结束当前事件处理依赖并等待接收一个新的事件后进入下一个事件处理依赖。 + +适用事件响应器操作:[`pause`](../../tutorial/plugin/matcher-operation.md#pause)。 + +
+ 示例插件 + +```python title=example.py +from nonebot import on_message + +foo = on_message() + +@foo.handle() +async def _(): + await foo.pause("test") +``` + +
+ +```python {13} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_call_send(event, "test", True) + ctx.should_paused() +``` + +## should_rejected + +定义事件响应器预期立即结束当前事件处理依赖并等待接收一个新的事件后再次执行当前事件处理依赖。 + +适用事件响应器操作:[`reject`](../../tutorial/plugin/matcher-operation.md#reject) +、[`reject_arg`](../../tutorial/plugin/matcher-operation.md#reject_arg) +和 [`reject_receive`](../../tutorial/plugin/matcher-operation.md#reject_receive)。 + +
+ 示例插件 + +```python title=example.py +from nonebot import on_message + +foo = on_message() + +@foo.got("key") +async def _(): + await foo.reject("test") +``` + +
+ +```python {13} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_call_send(event, "test", True) + ctx.should_rejected() +``` diff --git a/website/docs/advanced/unittest/test-matcher.md b/website/docs/advanced/unittest/test-matcher.md new file mode 100644 index 00000000..9733d93c --- /dev/null +++ b/website/docs/advanced/unittest/test-matcher.md @@ -0,0 +1,159 @@ +--- +sidebar_position: 2 +description: 测试事件响应和 API 调用 +--- + +# 测试事件响应和 API 调用 + +事件响应器通过 `Rule` 和 `Permission` 来判断当前事件是否触发事件响应器,通过 `send` 发送消息或使用 `call_api` 调用平台 API,这里我们将对上述行为进行测试。 + +## 定义预期响应行为 + +NoneBug 提供了六种定义 `Rule` 和 `Permission` 的预期行为的方法来进行测试: + +- `should_pass_rule` +- `should_not_pass_rule` +- `should_ignore_rule` +- `should_pass_permission` +- `should_not_pass_permission` +- `should_ignore_permission` + +以下为示例代码 + + +
+ 示例插件 + +```python title=example.py +from nonebot import on_message + +async def always_pass(): + return True + +async def never_pass(): + return False + +foo = on_message(always_pass) +bar = on_message(never_pass, permission=never_pass) +``` + +
+ +```python {12,13,19,20,27,28} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo, bar + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + 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_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_not_pass_rule() + ctx.should_not_pass_permission() + + # 如需忽略规则/权限不通过 + async with app.test_matcher(bar) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_ignore_rule() + ctx.should_ignore_permission() +``` + +## 定义预期 API 调用行为 + +在[事件响应器操作](../../tutorial/plugin/matcher-operation.md)和[调用平台 API](../../tutorial/call-api.md) 中,我们已经了解如何向发送消息或调用平台 `API`。接下来对 [`send`](../../tutorial/plugin/matcher-operation.md#send) 和 [`call_api`](../../api/adapters/index.md#Bot-call_api) 进行测试。 + +### should_call_send + +定义事件响应器预期发送消息,包括使用 [`send`](../../tutorial/plugin/matcher-operation.md#send)、[`finish`](../../tutorial/plugin/matcher-operation.md#finish)、[`pause`](../../tutorial/plugin/matcher-operation.md#pause)、[`reject`](../../tutorial/plugin/matcher-operation.md#reject) 以及 [`got`](../../tutorial/plugin/create-handler.md#使用-got-装饰器) 的 prompt 等方法发送的消息。 + +`should_call_send` 需要提供四个参数: + +- `event`:事件对象。 +- `message`:预期的消息对象,可以是`str`、[`Message`](../../api/adapters/index.md#Message) 或 [`MessageSegment`](../../api/adapters/index.md#MessageSegment)。 +- `result`:`send` 的返回值,将会返回给插件。 +- `**kwargs`:`send` 方法的额外参数。 + +
+ 示例插件 + +```python title=example.py +from nonebot import on_message + +foo = on_message() + +@foo.handle() +async def _(): + await foo.send("test") +``` + +
+ +```python {12} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_call_send(event, "test", True) +``` + +### should_call_api + +定义事件响应器预期调用机器人 API 接口,包括使用 `call_api` 或者直接使用 `bot.some_api` 的方式调用 API。 + +`should_call_api` 需要提供四个参数: + +- `api`:API 名称。 +- `data`:预期的请求数据。 +- `result`:`call_api` 的返回值,将会返回给插件。 +- `**kwargs`:`call_api` 方法的额外参数。 + +
+ 示例插件 + +```python +from nonebot import on_message +from nonebot.adapters import Bot + +foo = on_message() + + +@foo.handle() +async def _(bot: Bot): + await bot.example_api(test="test") +``` + +
+ +```python {12} +import pytest +from nonebug import App + +@pytest.mark.asyncio +async def test_matcher(app: App, load_plugins): + from awesome_bot.plugins.example import foo + + async with app.test_matcher(foo) as ctx: + bot = ctx.create_bot() + event = make_fake_event()() # 此处替换为平台事件 + ctx.receive_event(bot, event) + ctx.should_call_api("example_api", {"test": "test"}, True) +``` diff --git a/website/docs/tutorial/plugin/create-handler.md b/website/docs/tutorial/plugin/create-handler.md index 30649ebf..c71706d9 100644 --- a/website/docs/tutorial/plugin/create-handler.md +++ b/website/docs/tutorial/plugin/create-handler.md @@ -84,7 +84,7 @@ async def handle_func(): ```python {3-5} matcher = on_message() -@matcher.got("key") +@matcher.got("key", prompt="Key?") async def handle_func(key: Message = Arg()): # do something here ```