SimCord — discord.py testing framework. Simulate Discord, test your bot offline: no network, no token, no ToS.
Project description
SimCord
Test your discord.py bot against a simulated Discord — no network, no token, no test server.
Quickstart · Mental model · 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!"
Why SimCord?
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 — 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. |
Install
pip install simcord[pytest]
Requires Python 3.11+ and discord.py 2.7+. No dependencies beyond discord.py itself.
Quickstart
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.content
Not using pytest? async with simcord.run(bot) as env: gives you the same env in any async
test framework.
The mental model
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 — no 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 # query
Two details that make tests robust:
- Actors wait for the bot to finish reacting. Each verb settles the loop — running
callbacks, 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.
What you can test
| 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.
Configuration & diagnostics
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. |
@pytest.mark.simcord(strict_sync=False)
async def test_unsynced_command(simcord_env):
...
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.
How it works
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 vs. the alternatives
| SimCord | dpytest | Manual test server | |
|---|---|---|---|
| No network / no token | ✅ | ✅ | ❌ |
| Real discord.py internals | ✅ | Partial | ✅ |
| Slash commands & components | ✅ | ❌ | ✅ |
| Authentic error codes | ✅ | ❌ | ✅ |
| Time control | ✅ | ❌ | ❌ |
| Failure transcripts | ✅ | ❌ | ❌ |
| Maintained for discord.py 2.x | ✅ | ❌ | — |
Coming from dpytest? See the migration guide.
Documentation
| 🚀 Quickstart | Get a first test running. |
| 🧠 Core concepts | Builders, actors, queries — the model. |
| 📖 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. |
Contributing
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.
License
MIT. Unofficial — not affiliated with Discord Inc. or the discord.py project.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file simcord-1.1.0.tar.gz.
File metadata
- Download URL: simcord-1.1.0.tar.gz
- Upload date:
- Size: 359.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f20dada11616ad46d30a58d2d5000d0efb62ea20614c1486d23edb2c5b20cac7
|
|
| MD5 |
4c837c99c847fe39c9a258c3e19632bd
|
|
| BLAKE2b-256 |
cbdec04f2ba8b1fd6ad958ec917c53d6ac7c93605f5cc03d5f966297181d9bcf
|
Provenance
The following attestation bundles were made for simcord-1.1.0.tar.gz:
Publisher:
release.yml on SilentHacks/simcord
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
simcord-1.1.0.tar.gz -
Subject digest:
f20dada11616ad46d30a58d2d5000d0efb62ea20614c1486d23edb2c5b20cac7 - Sigstore transparency entry: 1966243966
- Sigstore integration time:
-
Permalink:
SilentHacks/simcord@c27fd9e0ce71649941fb9963d73c849d70e81ea7 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/SilentHacks
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c27fd9e0ce71649941fb9963d73c849d70e81ea7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file simcord-1.1.0-py3-none-any.whl.
File metadata
- Download URL: simcord-1.1.0-py3-none-any.whl
- Upload date:
- Size: 125.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9d9e0602496b550346f0e5d37fc296a3f103ca8a2141959847ba40745ed8df96
|
|
| MD5 |
4f4323cef023c17c2cea57e2c41fc13b
|
|
| BLAKE2b-256 |
625a84da91dd41c0ff0de2868e3e6515d5fe6b26c64704fd0249fc8fac0a568e
|
Provenance
The following attestation bundles were made for simcord-1.1.0-py3-none-any.whl:
Publisher:
release.yml on SilentHacks/simcord
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
simcord-1.1.0-py3-none-any.whl -
Subject digest:
9d9e0602496b550346f0e5d37fc296a3f103ca8a2141959847ba40745ed8df96 - Sigstore transparency entry: 1966244144
- Sigstore integration time:
-
Permalink:
SilentHacks/simcord@c27fd9e0ce71649941fb9963d73c849d70e81ea7 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/SilentHacks
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c27fd9e0ce71649941fb9963d73c849d70e81ea7 -
Trigger Event:
push
-
Statement type: