Skip to main content

SimCord

Test your discord.py bot against a simulated Discord. No network, no token, no test server.

CI Docs PyPI Python discord.py License: MIT

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!"

Real SimCord example suite running in pytest

The bundled example bot is executable, tested, and covers prefix commands, slash commands, permissions, cooldowns, modals, buttons, and persistent views.

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, 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.

Install

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.

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.

AI coding agents

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.

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, 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   # query

Two details that make tests robust:

  • Actors wait for the bot to finish reacting. Each verb settles the loop, running callbacks and draining asyncio.sleep chains 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.
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.

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 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

Documentation

🚀 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.

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 and not affiliated with Discord Inc. or the discord.py project.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

simcord-2.0.0.tar.gz (1.3 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

simcord-2.0.0-py3-none-any.whl (142.8 kB view details)

Uploaded Python 3

File details

Details for the file simcord-2.0.0.tar.gz.

File metadata

  • Download URL: simcord-2.0.0.tar.gz
  • Upload date:
  • Size: 1.3 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for simcord-2.0.0.tar.gz
Algorithm Hash digest
SHA256 5b2ca37e63e07a84d6de5f5123063e7f84916a61818742f12ba5d226b4d846c0
MD5 e3c98b4c751fa0a15a57acabba908c0b
BLAKE2b-256 6863b8d381e3dcd85ff33463bf5f50a8271a239ed5be9a3fcea7cffb137b5e1a

See more details on using hashes here.

Provenance

The following attestation bundles were made for simcord-2.0.0.tar.gz:

Publisher: release.yml on SilentHacks/simcord

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file simcord-2.0.0-py3-none-any.whl.

File metadata

  • Download URL: simcord-2.0.0-py3-none-any.whl
  • Upload date:
  • Size: 142.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for simcord-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 db3fb811e3794888103851b6b6b6eef03aa590d110bc20ef766e49e279db1c6e
MD5 6d33fbaa86ab6292258879772917fa77
BLAKE2b-256 689d49ca0255a01162079b833513ba846473df8e3a0f9079444c19ea2aaa2333

See more details on using hashes here.

Provenance

The following attestation bundles were made for simcord-2.0.0-py3-none-any.whl:

Publisher: release.yml on SilentHacks/simcord

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

2.0.1

2 files

This release

2.0.0 This release

2 files

1.2.2

2 files

1.2.1

2 files

1.2.0

2 files

1.1.0

2 files

1.0.1

2 files

1.0.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.0

2 files

0.8.1

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page