Test your discord.py bot against a simulated Discord. No network, no token, no test server.
Quickstart | Mental model | AI coding agents | Documentation | Parity matrix | Contributing
SimCord gives your bot a fake but faithful Discord to run against. Simulate users sending messages, invoking slash commands, clicking buttons and submitting modals, then assert on exactly what your bot did. It all runs in-process, with no network and no token.
async def test_ping(simcord_env):
channel = simcord_env.create_guild().create_text_channel("general")
alice = simcord_env.guild.add_member(simcord_env.create_user("alice"))
await alice.send(channel, "!ping") # full gateway round trip
assert channel.last_message.content == "Pong!"The bundled example bot is executable, tested, and covers prefix commands, slash commands, permissions, cooldowns, modals, buttons, and persistent views.
Your unit tests cover your business logic. The bugs that actually break Discord bots live in
the glue: converters, checks, permissions, a forgotten tree.sync(), a double-acknowledged
interaction, an oversized embed. That layer has historically only been testable by hand, in a
real server.
SimCord runs discord.py's real machinery, including its parsers, cache, command frameworks and views, against an in-memory model of Discord's REST API and gateway. Your bot code runs unmodified and can't tell the difference.
| 🎯 Authentic semantics | Server-side permission checks with real error codes (50013 Missing Permissions), interaction lifecycle rules (40060 on double-ack), role hierarchy, timeouts, ephemeral visibility and validation limits. |
| 🐛 Catches real bugs | Invoking a never-synced slash command fails your test, just like production. Clicking a disabled button is impossible, just like the client. An unhandled error in your bot fails the test by default. |
| ⚡ Fast & deterministic | No sleeps, no network, reproducible IDs and timestamps. SimCord tracks the bot's tasks and settles the event loop after every action, so assertions never flake. |
| ⏩ Time control | env.advance_time(180) fires view timeouts and resets cooldowns instantly. No real waiting. |
| 🔍 Readable failures | A failing test prints a transcript of every gateway event and REST call, in order: exactly what your bot did. |
| 📢 No silent fakes | Anything unimplemented raises RouteNotImplemented naming the route. Gaps fail loudly rather than returning a wrong answer. |
python -m pip install "simcord[pytest]"Or with uv:
uv add --dev "simcord[pytest]"Requires Python >=3.11 (tested on 3.11–3.14) and discord.py >=2.7.1,<3.
The locked CI matrix tests discord.py 2.7.1; a separate weekly workflow runs against
upstream master, rather than continuously testing every released 2.x version. No
dependencies beyond discord.py itself.
Tell the bundled pytest plugin how to build your bot:
# conftest.py
import pytest
from mybot import create_bot # however your project builds its commands.Bot
@pytest.fixture
def simcord_bot():
return create_bot()Then write tests against the simcord_env fixture. It hands you a running environment with
the bot already logged in and at READY:
import discord
async def test_ban_slash_command(simcord_env):
guild = simcord_env.create_guild()
channel = guild.create_text_channel("mod")
mods = guild.create_role("Mods", permissions=discord.Permissions(ban_members=True))
mod = guild.add_member(simcord_env.create_user("mod"), roles=[mods])
target = guild.add_member(simcord_env.create_user("spammer"))
result = await mod.slash(channel, "ban", user=target, reason="spam")
assert result.ephemeral
assert result.response.content == f"Banned {target.mention}: spam"
assert guild.get_ban(target) is not None
async def test_offer_expires(simcord_env):
channel = simcord_env.create_guild().create_text_channel("general")
alice = simcord_env.guild.add_member(simcord_env.create_user("alice"))
result = await alice.slash(channel, "offer") # bot replies with a View(timeout=180)
await simcord_env.advance_time(180) # instant; the view times out
assert "expired" in channel.last_message.contentNot using pytest? async with simcord.run(bot) as env: gives you the same env in any async
test framework.
Give Claude Code, Codex, Copilot, Cursor, or another coding agent a deterministic Discord runtime instead of letting it invent mocks that confirm its own assumptions.
Add this requirement to the task:
Use SimCord for the behavioral test. Drive the real bot through a user action,
keep the test offline, and never use a Discord token. Assert the user-visible
response and resulting Discord state, then run the focused test and project gates.
The AI coding agent guide includes a project-instructions block, a complete workflow, and the mistakes agents should avoid.
Every SimCord test is three moves: arrange the world, act as a user, assert the result. Three kinds of object map to those moves.
| Role | Nature | |
|---|---|---|
| Builders | Arrange the scenario: guilds, channels, roles, members. | Synchronous and omnipotent: the test is the narrator, so no permission checks. |
| Actors | Act as a real human: send, click, run a command. | Async and permission-checked: an actor can only do what that user physically could in the client. |
| Queries | Assert what happened. | Return real discord.py objects from the bot's own cache, so you assert with plain assert, not a DSL. |
import discord
async def test_welcome_on_join(simcord_env):
guild = simcord_env.create_guild() # builder
welcome = guild.create_text_channel("welcome") # builder
newbie = guild.add_member(simcord_env.create_user("ann")) # builder; fires the join event
assert f"Welcome {newbie.mention}" in welcome.last_message.content # queryTwo details that make tests robust:
- Actors wait for the bot to finish reacting. Each verb settles the loop, running
callbacks and draining
asyncio.sleepchains before returning, so the reply is already there when the next line runs. No sleeps, no flakes. If a handler hangs, settling fails fast with the pending tasks listed. - Impossible setups raise
SetupError, not a bot failure. Speaking in a channel a user can't see, or clicking a disabled button, points at your test, distinct from a bug in the bot.
See Core concepts for the full picture.
| Area | Actor verbs | Covers |
|---|---|---|
| Messages & prefix commands | send, edit, delete, typing |
Content, embeds, attachments, mentions, the commands.Bot prefix framework. |
| Slash commands | slash, autocomplete |
App command tree, tree.sync(), options, converters, checks, autocomplete. |
| Context menus | context_menu |
User and message commands. |
| Components & modals | click, select, submit_modal |
Buttons, selects, modals, View timeouts, persistent views across restarts. |
| Reactions | react, unreact |
Reaction add/remove events and wait_for. |
| Polls | vote, remove_vote |
Poll answers and results. |
| Voice & events | join_voice, leave_voice, set_voice, subscribe_event |
Voice state, scheduled-event subscriptions. |
| DMs | send_dm |
Direct-message channels and flows. |
Responses come back as a rich InteractionResult
exposing acknowledged, deferred, ephemeral, response, followups and modal. Threads,
permissions, role hierarchy, intents and audit logs are modelled too. The
parity matrix records exactly what's
implemented.
Pass options to simcord.run(bot, ...), or per-test via the @pytest.mark.simcord(...) marker
on the simcord_env fixture:
| Option | Default | Effect |
|---|---|---|
strict_sync |
True |
Invoking an unsynced slash command fails the test, as in production. |
check_errors |
True |
Errors your bot swallowed are re-raised at test teardown unless inspected, so bugs can't pass silently. |
approved_intents |
all | Simulate the developer-portal privileged-intent toggles; a missing intent raises PrivilegedIntentsRequired on connect. |
shard_count |
client setting | Shard count to use when an AutoShardedClient normally discovers it from Discord. |
settle_timeout |
5.0 seconds |
Maximum time an actor or env.settle() waits for runnable bot work. Per-call timeout= overrides it. |
Bot work remains owned across recognized external waits, timeout, cancellation, and restart. Declare exactly one intentional external wait with await env.external_wait(awaitable, reason="..."); unknown waits time out with diagnostics. Operations overlap-guard before mutating the virtual world, and teardown cancels bot-owned work without cancelling caller tasks.
@pytest.mark.simcord(strict_sync=False)
async def test_unsynced_command(simcord_env):
...Sharded bots use discord.py's normal API. Configure AutoShardedBot with its production
shard_count, then place test guilds with env.create_guild(shard_id=...). bot.shards,
get_shard(), shard readiness, presence and guild event routing behave normally.
When something goes wrong, the env tells you what happened:
env.transcript(): the ordered log of gateway events and REST calls, auto-attached to failing pytest tests.env.http_log: every REST request the bot made, to assert on or inspect.env.errors: exceptions the bot swallowed.env.inject_error("POST", "/channels/*/messages", status=500): make matching REST calls fail, to test your bot's error handling.env.restart_bot(): restart the bot while the virtual world persists, to prove persistent views re-attach.
discord.py has two narrow seams: every REST call funnels through HTTPClient.request, and every
gateway event enters through ConnectionState.parsers. SimCord replaces the first with a fake
routed to an in-memory backend, a single source of truth for guilds, channels, members,
messages, commands and interactions, and injects Discord-shaped payloads through the second.
Everything between those seams, which is everything your bot touches, is real discord.py running
unmodified.
test ──► builders/actors ──► virtual backend (single source of truth)
│ │
gateway payloads ▼ ▼ REST responses
ConnectionState.parsers FakeHTTPClient route table
│ ▲
▼ │
your real, unmodified bot
More in the architecture docs.
| SimCord | Direct mocks | Manual test server | |
|---|---|---|---|
| No network or token | Yes | Yes | No |
| Real discord.py dispatch | Yes | Usually no | Yes |
| Slash commands and components | Yes | You build the mock | Yes |
| Authentic permissions and errors | Yes | You build the mock | Yes |
| Deterministic time control | Yes | Limited | No |
| Failure transcripts | Yes | No | No |
| 🚀 Quickstart | Get a first test running. |
| 🧠 Core concepts | Builders, actors, and queries: the mental model. |
| AI coding agents | Reliable discord.py implementation and test workflow for coding agents. |
| 📖 Guides | Messages, interactions, components, permissions, threads, time control, diagnostics. |
| 🍳 Recipes | Copy-paste patterns for common cases. |
| 📋 Parity matrix | Exactly what's implemented. |
| 🔖 API reference | Every public object and verb. |
See CONTRIBUTING.md. Bug reports with a failing test are gold. If your bot hits an unimplemented route, the error names it. Please open a parity gap issue.
MIT. Unofficial and not affiliated with Discord Inc. or the discord.py project.
