Messages & prefix commands¶
Everything below drives a real commands.Bot end to end: the simulated user's message
travels through the gateway into discord.py's real parser, cache, prefix handling,
converters and checks; the bot's reply travels back through the (fake) REST API and is
broadcast to the cache like on production Discord. Nothing is stubbed in between.
Sending as a user¶
The core verb is actor.send(channel, content). It returns the real discord.Message that
was created, already visible in the cache:
send supports the things a real client can attach to a message:
# An attachment — (filename, bytes). attachment.read() works against the in-memory CDN.
await alice.send(channel, "look at this", attachments=[("notes.txt", b"hello")])
# A reply (message reference).
await alice.send(channel, "replying!", reply_to=message)
And the other text verbs mirror what a user can do to their own messages:
await alice.edit(message, "edited content") # only your own messages (else 50005)
await alice.delete(message) # your own, or with manage_messages
await alice.typing(channel) # triggers on_typing
Permissions are enforced
send checks send_messages (or send_messages_in_threads inside a thread), react
checks add_reactions, deleting someone else's message checks manage_messages, and so
on. If your test setup denies the action, you get a SetupError — see
Permissions.
Converters and checks run for real¶
Because the cache is populated through discord.py's actual parsers, converters resolve against genuine state — no special setup:
# The discord.Member converter resolves the mention against the real cache.
await alice.send(channel, f"!whois {alice.mention}")
The same is true for @commands.has_permissions(...), @commands.cooldown(...), custom
checks and error handlers — they are discord.py's own machinery running over real data.
Cooldowns are time-based, and you can fast-forward them with
env.advance_time.
Reactions¶
await alice.react(message, "👋") # checks add_reactions; emits the gateway event
await alice.unreact(message, "👋")
# The bot sees its own and others' reactions through on_reaction_add etc.
fetched = channel.last_message
assert any(str(r.emoji) == "👋" for r in fetched.reactions)
More on reaction-driven flows in Threads, reactions & DMs.
Direct messages¶
A user can DM the bot, and the bot can DM back:
await alice.send_dm("hello bot") # on_message fires with message.guild is None
history = alice.user.dm_channel.history() # what the bot DM'd back
See Threads, reactions & DMs for the DM nuances (e.g. a bot
DMing a user who shares no mutual context fails with 403 50007, matching Discord).
Asserting what the bot did¶
Queries return real discord.Message objects, so assert with normal attribute access:
assert channel.last_message.content == "Pong!"
assert channel.history()[-1].embeds[0].title == "Help"
assert channel.pinned_messages() != []
assert channel.last_message.reference.message_id == original.id
channel.history() returns every message oldest-first; channel.last_message is the most
recent or None. Pass viewer= to get an ephemeral-aware view — see
Slash commands → Ephemeral visibility.
Error capture¶
Unhandled errors from command handlers, app-command callbacks and event listeners are
collected into env.errors, so the classic "the bot silently failed" bug becomes
assertable:
await alice.send(channel, "!broken")
assert isinstance(simcord_env.errors[-1].original, discord.Forbidden)
assert simcord_env.errors[-1].original.code == 50013
Errors fail tests by default
If the bot raised errors during a test and the test never inspected env.errors (or
called env.raise_errors()), simcord.run re-raises them as an ExceptionGroup at
teardown — a bot bug can't pass silently. Reading env.errors counts as inspecting
(your assertions take over). Assert cleanliness explicitly with env.raise_errors(),
or opt out entirely with simcord.run(bot, check_errors=False).
Full treatment in Errors & diagnostics.
Validation limits¶
Real Discord limits are enforced with real error codes. Message content over 2000
characters, or embeds totalling over 6000 characters, raise discord.HTTPException with
code 50035 — exactly as in production:
async def test_rejects_oversized(simcord_env):
...
with pytest.raises(discord.HTTPException) as exc:
await some_path_that_sends_too_much()
assert exc.value.code == 50035
Next¶
- Slash commands — app commands, context menus and the interaction lifecycle.
- Components & modals — buttons, selects and modals.
- Recipes — ready-made patterns built on these verbs.