____ _ _ _ _
| __ ) ___ | |___ ___ __ (_) __ _| |__ | |_
| _ \ / _ \| __\ \ /\ / / '__|| |/ _` | '_ \| __|
| |_) | (_) | |_ \ V V /| | | | (_| | | | | |_
|____/ \___/ \__| \_/\_/ |_| |_|\__, |_| |_|\__|
|___/
Discord bot e2e tests through real channels, real messages, real assertions.
End-to-end testing for Discord bots, built on discord.py and pytest.
Botwright uses a real tester bot account to talk to your target bot in Discord.
Tests send real Discord messages, wait for target-bot responses, and assert on
real discord.Message objects.
Note Botwright cannot drive slash-only command surfaces. For better e2e coverage, prefer
discord.pyhybrid commands and hybrid groups, or keep command behavior in shared application code that both slash and prefix handlers call. Botwright can then exercise the prefix path while still covering the underlying command logic.
import pytest
from botwright import TestSession
@pytest.mark.asyncio
async def test_ping(session: TestSession):
reply = await session.send_and_wait("!ping")
assert reply.content == "pong"
assert reply.author.id == session.target_bot_idUnit tests are useful, but Discord bots often fail at the boundary: intents, permissions, channel routing, embeds, command prefixes, bot-to-bot behavior, and Discord API timing.
Botwright tests that boundary directly:
- Runs inside pytest, so you keep normal
assert, fixtures, parametrization, and reporting. - Uses one tester bot per pytest session for fast startup.
- Uses one isolated temporary channel per test by default.
- Supports fixed-channel tests for bots that only monitor specific channels.
- Returns real
discord.pyobjects instead of wrapping responses in a custom DSL.
uv add botwrightFor local development in this repository:
uv syncCreate two bots in the same dedicated test guild:
- Target bot: the bot you want to test.
- Tester bot: a separate bot account controlled by Botwright.
The tester bot needs:
Send MessagesAdd Reactionsif tests use reaction helpersRead Message HistoryView ChannelManage Channelsif Botwright will create temporary channels- Message Content Intent enabled in the Discord Developer Portal
If your target bot ignores messages from bot accounts, add a test-mode bypass. For example:
if message.author.bot and os.getenv("TEST_MODE") != "1":
returnBotwright reads environment variables
Required:
export BOTWRIGHT_TESTER_TOKEN="..."
export BOTWRIGHT_GUILD_ID="..."
export BOTWRIGHT_TARGET_BOT_ID="..."Optional:
| Variable | Default | Description |
|---|---|---|
BOTWRIGHT_CHANNEL_ID |
unset | Existing text channel to use instead of creating temporary channels. |
BOTWRIGHT_CHANNEL_PREFIX |
botwright- |
Prefix for temporary channel names. |
BOTWRIGHT_DEFAULT_TIMEOUT |
10 |
Default seconds to wait for expected messages. |
BOTWRIGHT_READY_TIMEOUT |
30 |
Seconds to wait for the tester bot to connect. |
BOTWRIGHT_KEEP_CHANNELS |
never |
never, failed, or always. |
Command-line options override environment variables:
pytest tests/e2e \
--botwright-timeout=20 \
--botwright-keep-channels=failed \
--botwright-channel-prefix=mybot-Available options:
--botwright-check--botwright-channel-id--botwright-channel-prefix--botwright-timeout--botwright-ready-timeout--botwright-keep-channels=never|failed|always--botwright-no-banner
Validate Discord configuration without running tests:
botwright checkThe same check is also available through pytest:
pytest --botwright-checkBoth commands connect the tester bot, verify the guild, verify tester and target
membership, check fixed-channel permissions when BOTWRIGHT_CHANNEL_ID is set,
and then exit.
Use expect_reply() or expect_message() when the bot may reply immediately.
The listener is registered before the message is sent.
@pytest.mark.asyncio
async def test_help_embed(session: TestSession):
async with session.expect_reply() as reply:
await session.send("!help")
assert reply.value is not None
assert reply.value.embeds
assert reply.value.author.id == session.target_bot_idUse send_and_wait() for simple request-response tests.
@pytest.mark.asyncio
async def test_echo(session: TestSession):
reply = await session.send_and_wait("!echo hello")
assert reply.content == "hello"Use wait_for_message() when something else already triggered the response.
@pytest.mark.asyncio
async def test_background_notification(session: TestSession):
message = await session.wait_for_message(
predicate=lambda msg: "done" in msg.content.lower(),
timeout=30,
)
assert message.author.id == session.target_bot_idBy default, Botwright waits for messages from the configured target bot. Use
ANY_AUTHOR when a test should accept a message from any user or bot:
from botwright import ANY_AUTHOR
@pytest.mark.asyncio
async def test_anyone_can_trigger_audit_log(session: TestSession):
message = await session.wait_for_message(
from_user_id=ANY_AUTHOR,
predicate=lambda msg: "audit complete" in msg.content.lower(),
)
assert message.channel.id == session.channel.idUse channel_id= when a command writes to another channel, or
any_channel=True when the channel is part of the assertion:
@pytest.mark.asyncio
async def test_audit_log_side_effect(session: TestSession):
await session.send("!warn @member")
audit = await session.wait_for_message(
channel_id=123456789012345678,
predicate=lambda msg: "warned" in msg.content.lower(),
)
assert audit.author.id == session.target_bot_idThe same channel options are available on expect_message(), expect_reply(),
and send_and_wait().
Use add_reaction() and remove_reaction() when your target bot responds to
message reactions, such as react-role flows:
@pytest.mark.asyncio
async def test_react_role(session: TestSession):
panel = await session.wait_for_message(predicate=lambda msg: msg.embeds)
await session.add_reaction(panel, "<:thumbsup:123456789012345678>")
confirmation = await session.wait_for_message(
predicate=lambda msg: "role added" in msg.content.lower(),
)
assert confirmation.author.id == session.target_bot_idBy default, Botwright creates a temporary channel for each test and deletes it after the test finishes. This gives strong isolation.
Some bots only monitor a specific channel. In that case, use fixed-channel mode:
pytest tests/e2e --botwright-channel-id=123456789012345678or per test:
@pytest.mark.botwright(channel_id=123456789012345678)
@pytest.mark.asyncio
async def test_channel_bound_bot(session: TestSession):
reply = await session.send_and_wait("!status")
assert reply.contentIn fixed-channel mode:
- Botwright does not create or delete the channel.
Manage Channelsis not required.- Botwright still filters messages by channel ID and target bot ID.
- Test isolation is your responsibility. Avoid parallel tests in the same fixed channel unless your predicates make each expected response unique.
Discord e2e tests often describe workflows, and workflows are usually ordered. Prefer writing those workflows as one explicit async test instead of relying on cross-test ordering:
@pytest.mark.asyncio
async def test_onboarding_flow(session: TestSession):
welcome = await session.send_and_wait("!start")
assert "welcome" in welcome.content.lower()
next_step = await session.send_and_wait("!next")
assert next_step.embedsThis keeps failures local: pytest reports the flow that failed, and the code shows the exact sequence that led to the failure.
If you need ordered test functions, use an ordering plugin such as
pytest-order. Botwright does not provide its own ordering layer because pytest
already has good ecosystem support for that problem.
Botwright registers a pytest plugin named botwright.
Installed packages are auto-discovered by pytest. If plugin auto-discovery is disabled, load it explicitly:
pytest -p botwright.pluginFixtures:
| Fixture | Scope | Description |
|---|---|---|
botwright_config |
session | Validated Botwright configuration. |
tester_bot |
session | Connected tester TesterBot. |
test_channel |
function | Temporary or configured text channel. |
session |
function | TestSession bound to the current channel. |
Botwright automatically runs tests using these fixtures on pytest-asyncio's session event loop. This keeps Discord client, HTTP, and gateway state on the same loop.
If you explicitly mark a Botwright test with @pytest.mark.asyncio, use
loop_scope="session":
@pytest.mark.asyncio(loop_scope="session")
async def test_ping(session: TestSession):
...Botwright rejects conflicting loop scopes because discord.py clients and HTTP
sessions cannot be moved between event loops safely.
tester_bot is session-scoped. One pytest process starts one tester bot and
shares it across all Botwright tests in that process, even when those tests live
in multiple files:
pytest tests/e2eSeparate pytest invocations start separate tester bot sessions:
pytest tests/e2e/test_a.py
pytest tests/e2e/test_b.pyIf you use pytest-xdist, each worker process has its own session-scoped
fixtures. That means each worker starts its own tester bot. For now, run
Botwright tests without xdist unless you intentionally partition channels and
bot accounts per worker.
TesterBot is a small discord.Client subclass. It exposes
add_listener() and remove_listener() for off-channel observation without
dropping into wait_for() manually:
@pytest.mark.asyncio
async def test_observes_raw_messages(session: TestSession):
seen = []
async def on_message(message):
seen.append(message)
session.bot.add_listener(on_message, "on_message")
try:
await session.send("!fanout")
await session.wait_for_message(any_channel=True)
finally:
session.bot.remove_listener(on_message, "on_message")
assert seenUse @pytest.mark.botwright(...) to override settings for one test:
@pytest.mark.botwright(timeout=30, keep_channel=True)
@pytest.mark.asyncio
async def test_slow_flow(session: TestSession):
reply = await session.send_and_wait("!slow")
assert reply.content == "complete"Supported marker arguments:
timeout: default wait timeout for that test'ssessionkeep_channel: keep or delete a temporary channel for that testchannel_id: use an existing text channel for that test
session.channel
: The Discord text channel for the current test.
session.target_bot_id
: The configured target bot user ID.
await session.send(content)
: Send a message as the tester bot. Returns the tester bot's
discord.Message.
await session.add_reaction(message, emoji)
: Add a reaction as the tester bot.
await session.remove_reaction(message, emoji)
: Remove the tester bot's reaction from a message.
await session.wait_for_message(from_user_id=None, predicate=None, timeout=None, channel_id=None, any_channel=False)
: Wait for a matching message. By default, waits for the configured target bot
in the current channel.
async with session.expect_message(...) as message
: Register a message waiter before the code inside the context block runs.
The resulting message is available as message.value after the block exits.
async with session.expect_reply(...) as reply
: Convenience helper for the common target-bot reply case. It uses the same
waiter machinery as expect_message(), but reads better in request-response
tests.
await session.send_and_wait(content, from_user_id=None, predicate=None, timeout=None, channel_id=None, any_channel=False)
: Register a reply waiter, send a message, and return the matching response.
Predicates receive a real discord.Message:
reply = await session.send_and_wait(
"!help",
predicate=lambda msg: bool(msg.embeds),
)
assert reply.embeds[0].titleUse verbose pytest output while developing:
pytest tests/e2e -s -v --botwright-keep-channels=failedUse --botwright-no-banner in CI if you prefer compact logs:
pytest tests/e2e --botwright-no-bannerFor the standalone check command, use:
botwright check --no-bannerBotwright prints setup diagnostics:
- Configuration loaded
- Tester bot connected
- Guild membership verified
- Channel selected or created
- Required permissions verified
- Temporary channel deleted or retained
Timeout errors include:
- Expected channel ID and author ID
- Gateway event counters
- Messages observed by the wait
- Recent channel history, including embed counts, titles, descriptions, and field counts when available
If a test fails, --botwright-keep-channels=failed leaves the Discord channel in
place so you can inspect the conversation.
Run the setup check before debugging individual tests:
botwright checkRun the included demo target bot:
TEST_MODE=1 TARGET_BOT_TOKEN="..." python examples/target_bot/bot.pyRun the example tests:
pytest examples/ -v- Slash commands are not supported. Discord does not allow one bot account to invoke another bot's slash commands through the public bot API.
- Component clicks, select menus, and modals are not implemented. They require interaction requests that discord.py does not expose for bot-to-bot testing as a stable public API.
- Member join, role, and voice gateway events are not synthesized for the target bot. Prefer testing those with in-process unit tests, or with an explicit external setup step that changes real Discord state.
- Fixed-channel tests are not isolated unless your test design makes them isolated.
For bots with caches or external state, seed the SDK or database first, then use an explicit Discord assertion that proves the target bot observed the new state:
@pytest.mark.asyncio
async def test_seeded_plan_status(session: TestSession, plana_sdk):
plan = await plana_sdk.create_plan(name="launch")
status = await session.send_and_wait(
f"!plan status {plan.id}",
predicate=lambda msg: "launch" in msg.content.lower(),
)
assert status.author.id == session.target_bot_id