Skip to content

Errors & diagnostics

SimCord is built around a simple principle: a bot bug should never pass silently. This guide covers the tools that make what your bot did — and what went wrong — visible and assertable.

Captured errors (env.errors)

Unhandled exceptions from command handlers, app-command callbacks and event listeners don't crash the bot (just as they don't in production) — but SimCord captures them 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

env.errors is a list of the exceptions the bot raised, in order. (CommandNotFound is excluded — a non-command message isn't a bug.)

Errors fail tests by default

Uninspected errors are re-raised at teardown

If the bot raised errors during a test and the test never inspected them, simcord.run re-raises them as an ExceptionGroup when the environment closes — so a swallowed bug can't slip past a green test. Reading env.errors counts as inspecting: your assertions take over from there.

You have three ways to handle this:

# 1. Inspect — your assertions own correctness.
assert simcord_env.errors == []

# 2. Assert cleanliness explicitly, anywhere mid-test.
simcord_env.raise_errors()   # raises an ExceptionGroup of everything captured, or nothing

# 3. Opt out for a test that genuinely doesn't care.
async with simcord.run(bot, check_errors=False) as env:
    ...

env.raise_errors() is the one-call way to assert "the bot ran cleanly": it raises an ExceptionGroup of everything captured (even a single error) and does nothing if there were none.

Assertions

You can assert with plain assert on the result and query objects, but SimCord also ships a few helpers whose failure messages show what the bot actually did — so a red test explains itself. They're plain functions (no pytest required) importable from the top level:

from simcord import assert_sent, assert_responded, assert_error

await alice.send(channel, "!ping")
assert_sent(channel, content="Pong!")           # the channel's last visible message

result = await alice.slash(channel, "hello")
assert_responded(result, contains="hi", ephemeral=True)   # the interaction's response

Each field (content, contains, embed_title, ephemeral) is checked only when you pass it. When an assertion fails, the message includes the real output — e.g. assert_sent prints the channel's recent history, and assert_responded prints whether the bot deferred or opened a modal instead:

AssertionError: last message did not match:
  content: expected 'Pong!', got 'Pnog!'
recent messages:
  - 'Pnog!'

For errors, assert_error replaces the clunky any(isinstance(e, ...) for e in env.errors) idiom and unwraps discord.py's CommandInvokeError.original for you:

env.inject_error("POST", "/channels/*/messages", status=403, code=50013)
await alice.send(channel, "!post")
assert_error(env, discord.Forbidden, code=50013)   # matches the wrapped original
assert_no_errors(env)                               # the symmetric "ran cleanly" check

assert_error reads env.errors, which counts as inspecting them (see below), so it also satisfies the teardown guard.

The transcript

env.transcript() returns a human-readable, ordered record of everything that crossed SimCord's two seams — each gateway event injected and each REST call the bot made:

print(simcord_env.transcript())
# GATEWAY MESSAGE_CREATE            author=alice content='!ping'
# REST    POST /channels/123/...    content='Pong!'

The pytest plugin attaches this to failing tests automatically, so a failure shows you exactly what the bot did, in order — usually enough to diagnose without a debugger.

The HTTP log (env.http_log)

For fine-grained assertions, env.http_log is the list of every REST call the bot made, as (method, path, json_body) tuples:

posts = [c for c in simcord_env.http_log if c[0] == "POST" and "/messages" in c[1]]
assert len(posts) == 1                       # the bot sent exactly one message
assert posts[0][2]["content"] == "Pong!"

This is how you assert the bot didn't do something (e.g. didn't double-post), or made exactly the calls you expect.

Injecting API failures

Real bots have to survive Discord hiccups. env.inject_error makes matching REST calls fail so you can test the bot's error handling:

async def test_handles_api_outage(simcord_env):
    simcord_env.inject_error("POST", "/channels/*/messages", status=500)
    ...   # assert your bot degrades gracefully / retries / reports

Parameters:

Parameter Default Meaning
method HTTP method to match, or "*" for any.
path fnmatch pattern against the API path, e.g. "/channels/*/messages".
status 500 HTTP status to return.
code 0 Discord JSON error code (e.g. 50013).
message injected default The error message.
times 1 How many matching calls fail; None keeps the fault active for the rest of the test.
# Fail the first two attempts with a rate-limit-style 429, then let it through:
simcord_env.inject_error("POST", "/channels/*/messages", status=429, times=2)

# Make every ban call fail for the whole test with a realistic 50013:
simcord_env.inject_error("PUT", "/guilds/*/bans/*", status=403, code=50013, times=None)

Because failures surface as genuine discord.Forbidden / discord.HTTPException with real codes, your except discord.HTTPException: branches are exercised exactly as in production.

Unimplemented routes

If your bot hits a route SimCord doesn't implement yet, it raises RouteNotImplemented naming the route — never a silent fake success. That signal is not a discord.HTTPException, so a broad except discord.HTTPException in your bot can't swallow it. If you hit one, open a parity-gap issue. See the parity matrix.

Next