diff --git a/maubot/testing/__init__.py b/maubot/testing/__init__.py
new file mode 100644
index 0000000..1fcdfc0
--- /dev/null
+++ b/maubot/testing/__init__.py
@@ -0,0 +1,17 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2023 Aurélien Bompard
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from .bot import TestBot, make_message # noqa: F401
+from .fixtures import * # noqa: F401,F403
diff --git a/maubot/testing/bot.py b/maubot/testing/bot.py
new file mode 100644
index 0000000..0519016
--- /dev/null
+++ b/maubot/testing/bot.py
@@ -0,0 +1,100 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2023 Aurélien Bompard
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import asyncio
+import time
+
+from attr import dataclass
+
+from maubot.matrix import MaubotMatrixClient, MaubotMessageEvent
+from mautrix.api import HTTPAPI
+from mautrix.types import (
+ EventContent,
+ EventType,
+ MessageEvent,
+ MessageType,
+ RoomID,
+ TextMessageEventContent,
+)
+
+
+@dataclass
+class MatrixEvent:
+ room_id: RoomID
+ event_type: EventType
+ content: EventContent
+ kwargs: dict
+
+
+class TestBot:
+ """A mocked bot used for testing purposes.
+
+ Send messages to the mock Matrix server with the ``send()`` method.
+ Look into the ``responded`` list to get what server has replied.
+ """
+
+ def __init__(self, mxid="@botname:example.com", mxurl="http://matrix.example.com"):
+ api = HTTPAPI(base_url=mxurl)
+ self.client = MaubotMatrixClient(api=api)
+ self.responded = []
+ self.client.mxid = mxid
+ self.client.send_message_event = self._mock_send_message_event
+
+ async def _mock_send_message_event(self, room_id, event_type, content, txn_id=None, **kwargs):
+ self.responded.append(
+ MatrixEvent(room_id=room_id, event_type=event_type, content=content, kwargs=kwargs)
+ )
+
+ async def dispatch(self, event_type: EventType, event):
+ tasks = self.client.dispatch_manual_event(event_type, event, force_synchronous=True)
+ return await asyncio.gather(*tasks)
+
+ async def send(
+ self,
+ content,
+ html=None,
+ room_id="testroom",
+ msg_type=MessageType.TEXT,
+ sender="@dummy:example.com",
+ timestamp=None,
+ ):
+ event = make_message(
+ content,
+ html=html,
+ room_id=room_id,
+ msg_type=msg_type,
+ sender=sender,
+ timestamp=timestamp,
+ )
+ await self.dispatch(EventType.ROOM_MESSAGE, MaubotMessageEvent(event, self.client))
+
+
+def make_message(
+ content,
+ html=None,
+ room_id="testroom",
+ msg_type=MessageType.TEXT,
+ sender="@dummy:example.com",
+ timestamp=None,
+):
+ """Make a Matrix message event."""
+ return MessageEvent(
+ type=EventType.ROOM_MESSAGE,
+ room_id=room_id,
+ event_id="test",
+ sender=sender,
+ timestamp=timestamp or int(time.time() * 1000),
+ content=TextMessageEventContent(msgtype=msg_type, body=content, formatted_body=html),
+ )
diff --git a/maubot/testing/fixtures.py b/maubot/testing/fixtures.py
new file mode 100644
index 0000000..e975782
--- /dev/null
+++ b/maubot/testing/fixtures.py
@@ -0,0 +1,135 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2023 Aurélien Bompard
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from pathlib import Path
+import asyncio
+import logging
+
+from ruamel.yaml import YAML
+import aiohttp
+import pytest
+import pytest_asyncio
+
+from maubot import Plugin
+from maubot.loader import PluginMeta
+from maubot.standalone.loader import FileSystemLoader
+from mautrix.util.async_db import Database
+from mautrix.util.config import BaseProxyConfig, RecursiveDict
+from mautrix.util.logging import TraceLogger
+
+from .bot import TestBot
+
+
+@pytest_asyncio.fixture
+async def maubot_test_bot():
+ return TestBot()
+
+
+@pytest.fixture
+def maubot_upgrade_table():
+ return None
+
+
+@pytest.fixture
+def maubot_plugin_path():
+ return Path(".")
+
+
+@pytest.fixture
+def maubot_plugin_meta(maubot_plugin_path):
+ yaml = YAML()
+ with open(maubot_plugin_path.joinpath("maubot.yaml")) as fh:
+ plugin_meta = PluginMeta.deserialize(yaml.load(fh.read()))
+ return plugin_meta
+
+
+@pytest_asyncio.fixture
+async def maubot_plugin_db(tmp_path, maubot_plugin_meta, maubot_upgrade_table):
+ if not maubot_plugin_meta.get("database", False):
+ return
+ db_path = tmp_path.joinpath("maubot-tests.db").as_posix()
+ db = Database.create(
+ f"sqlite:{db_path}",
+ upgrade_table=maubot_upgrade_table,
+ log=logging.getLogger("db"),
+ )
+ await db.start()
+ yield db
+ await db.stop()
+
+
+@pytest.fixture
+def maubot_plugin_class():
+ return Plugin
+
+
+@pytest.fixture
+def maubot_plugin_config_class():
+ return BaseProxyConfig
+
+
+@pytest.fixture
+def maubot_plugin_config_dict():
+ return {}
+
+
+@pytest.fixture
+def maubot_plugin_config_overrides():
+ return {}
+
+
+@pytest.fixture
+def maubot_plugin_config(
+ maubot_plugin_path,
+ maubot_plugin_config_class,
+ maubot_plugin_config_dict,
+ maubot_plugin_config_overrides,
+):
+ yaml = YAML()
+ with open(maubot_plugin_path.joinpath("base-config.yaml")) as fh:
+ base_config = RecursiveDict(yaml.load(fh))
+ maubot_plugin_config_dict.update(maubot_plugin_config_overrides)
+ return maubot_plugin_config_class(
+ load=lambda: maubot_plugin_config_dict,
+ load_base=lambda: base_config,
+ save=lambda c: None,
+ )
+
+
+@pytest_asyncio.fixture
+async def maubot_plugin(
+ maubot_test_bot,
+ maubot_plugin_db,
+ maubot_plugin_class,
+ maubot_plugin_path,
+ maubot_plugin_config,
+ maubot_plugin_meta,
+):
+ loader = FileSystemLoader(maubot_plugin_path, maubot_plugin_meta)
+ async with aiohttp.ClientSession() as http:
+ instance = maubot_plugin_class(
+ client=maubot_test_bot.client,
+ loop=asyncio.get_running_loop(),
+ http=http,
+ instance_id="tests",
+ log=TraceLogger("test"),
+ config=maubot_plugin_config,
+ database=maubot_plugin_db,
+ webapp=None,
+ webapp_url=None,
+ loader=loader,
+ )
+ await instance.internal_start()
+ yield instance
diff --git a/optional-requirements.txt b/optional-requirements.txt
index 6d87db3..0e45b97 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -5,3 +5,7 @@
python-olm>=3,<4
pycryptodome>=3,<4
unpaddedbase64>=1,<3
+
+#/testing
+pytest
+pytest-asyncio
diff --git a/setup.py b/setup.py
index 24f9e00..79a0c6c 100644
--- a/setup.py
+++ b/setup.py
@@ -57,6 +57,8 @@ setuptools.setup(
entry_points="""
[console_scripts]
mbc=maubot.cli:app
+ [pytest11]
+ maubot=maubot.testing
""",
data_files=[
(".", ["maubot/example-config.yaml"]),