Testing your bot
cordless.testing builds fake interaction payloads and dispatches them through your bot’s real router, the same code path a deployed Lambda runs. There is no HTTP request involved, so signature verification never runs, and it works the same whether your Cordless() instance was constructed with a real DISCORD_PUBLIC_KEY or not.
Import the module as a namespace:
from cordless import testingRunning your tests
Section titled “Running your tests”cordless.testing is a plain library, not a test runner, so it needs pytest (or any test runner you like) alongside it:
uv add --dev pytesttesting.invoke() is async, but your test functions don’t have to be. cordless’s own test suite uses plain def tests and asyncio.run() to drive them, which needs nothing beyond pytest itself, no plugin, no config:
from asyncio import runfrom cordless import testing
def test_ping(): response, ctx = run(testing.invoke(bot, "ping")) assert response["data"]["content"] == "pong"pytestEvery example on this page uses that pattern. If you’d rather write async def test_ping(): ... with await directly, that works too, it just needs pytest-asyncio installed and asyncio_mode = "auto" set in [tool.pytest.ini_options] (pyproject.toml) or pytest.ini first, otherwise pytest silently skips the test body with a “coroutine was never awaited” warning instead of actually running it.
A first test
Section titled “A first test”from cordless import Cordless, testing
bot = Cordless(public_key=None)
@bot.command("ping", description="Check the bot is alive")async def ping(ctx): await ctx.send("pong")from asyncio import run
def test_ping(): response, ctx = run(testing.invoke(bot, "ping")) assert response["data"]["content"] == "pong"invoke() returns a pair: the decoded response Discord would receive, and the Context the handler ran with. response is Discord’s raw interaction response shape, {"type": 4, "data": {"content": "pong"}} for a normal message, so assertions read directly against the API you already know. ctx is there for anything that never made it into the response, ctx.member.permissions, ctx.custom_id_args, and so on.
Passing a bare command name to invoke() is shorthand for building the interaction and dispatching it in one step. For anything the shorthand does not cover, build the interaction yourself and pass it instead:
interaction = testing.command("ping")response, ctx = run(testing.invoke(bot, interaction))Options
Section titled “Options”Pass options as a plain dictionary. The Discord option type is inferred from each value’s Python type:
def test_buy(): response, _ = run(testing.invoke(bot, "buy", options={"item": "sword", "qty": 3})) assert response["data"]["content"] == "Bought 3x sword"str, int, bool, and float values map to Discord’s string, integer, boolean, and number option types respectively.
Subcommand paths
Section titled “Subcommand paths”testing.command() accepts the same "name", "parent/sub", and "parent/group/sub" paths as @bot.command(), nesting the options correctly underneath:
def test_shop_buy(): response, _ = run(testing.invoke(bot, "shop/buy", options={"item": "shield"})) assert response["data"]["content"] == "Bought a shield"Guild context and members
Section titled “Guild context and members”By default, an interaction has no guild_id, matching a DM or user-installed context: ctx.user is set, ctx.member and ctx.guild are None. Pass guild_id for a guild context instead:
response, ctx = run(testing.invoke(bot, "ping", guild_id="500"))assert ctx.guild.id == "500"ctx.guild only ever carries id, locale, and features, the same partial object Discord actually attaches to an interaction. Pass guild={"id": "500", "locale": "en-GB", "features": ["COMMUNITY"]} if a handler reads one of those fields.
For a member with roles or permissions, build one with testing.member() and pass it as member:
from cordless import Permissions
def test_admin_only_command(): admin = testing.member(permissions=Permissions(manage_guild=True)) response, ctx = run(testing.invoke(bot, "wipe", member=admin)) assert ctx.member.permissions.manage_guild is TruePassing member implies a guild context automatically, there is no need to set guild_id separately unless a specific id matters to the handler.
Context menu commands
Section titled “Context menu commands”Pass target, the actual user or message object you want resolved, and testing.command() wires up target_id and the resolved block for you:
def test_inspect_context_menu(): target = {"id": "42", "username": "someone"} interaction = testing.command("inspect", target=target) response, _ = run(testing.invoke(bot, interaction)) assert "someone" in response["data"]["content"]target_type picks user (2, the default) or message (3).
Buttons and selects
Section titled “Buttons and selects”def test_confirm_button(): response, _ = run(testing.invoke(bot, testing.button("confirm"))) assert response["data"]["content"] == "Confirmed"Prefix-matched custom ids work the same as in production, the handler is looked up by the part before the first :, and the rest lands on ctx.custom_id_args:
def test_shop_item_button(): response, ctx = run(testing.invoke(bot, testing.button("shop:item1"))) assert ctx.custom_id_args == ["item1"]Selects take a kind, "string" (the default), "user", "role", "mentionable", or "channel":
def test_pick_role(): role = {"id": "999", "name": "Moderator"} response, ctx = run(testing.invoke(bot, testing.select("pickrole", values=[role], kind="role")))For "user", "role", and "channel" selects, passing the actual resolved objects in values stitches Discord’s resolved block in automatically, so ctx.resolved_roles and friends are populated without building that block by hand. Plain id strings still work if a handler only reads ctx.values. For "mentionable" selects, which mix users and roles, pass plain ids and build resolved yourself if a handler needs it.
Pass message to make Discord’s message object available as ctx.message:
testing.button("confirm", message={"id": "1", "content": "Are you sure?"})Modals
Section titled “Modals”values is a plain dictionary of component custom id to submitted text:
def test_signup_modal(): interaction = testing.modal("signup", values={"name_field": "shiv"}) response, ctx = run(testing.invoke(bot, interaction)) assert ctx.modal_values == {"name_field": "shiv"}Autocomplete
Section titled “Autocomplete”Pass focused to mark which option the user is currently typing into:
def test_item_autocomplete(): interaction = testing.autocomplete("shop", {"item": "sw"}, focused="item") response, ctx = run(testing.invoke(bot, interaction)) assert ctx.focused_value == "sw" assert {"name": "sword", "value": "sword"} in response["data"]["choices"]File attachments
Section titled “File attachments”A handler that calls ctx.send/ctx.edit with files= gets its response decoded back to the same plain dict shape as any other response, invoke() handles the multipart/base64 encoding Discord’s API actually uses for attachments:
def test_export_report(): response, _ = run(testing.invoke(bot, "export")) assert response["data"]["attachments"] == [{"id": 0, "filename": "report.pdf"}]Deferred handlers
Section titled “Deferred handlers”A defer=True handler dispatched normally hits the real defer-to-worker path, which needs CORDLESS_WORKER_FUNCTION set and a live worker Lambda to invoke, exactly as it would after a real deploy. Pass worker_mode=True to skip straight to the handler’s actual body instead, the same code path the worker Lambda itself runs:
def test_slow_command(monkeypatch): sent = [] monkeypatch.setattr("cordless.defer.patch_followup", lambda app_id, token, payload: sent.append(payload))
response, ctx = run(testing.invoke(bot, "slowthing", worker_mode=True)) assert sent == [{"content": "Done!"}]In worker mode, a handler calling ctx.send() or ctx.edit() makes a genuine followup request to Discord (cordless.defer.patch_followup). Patch that function, or post_followup/delete_original for the calls those methods make, to capture what was sent instead of making a real network call.
Scheduled handlers
Section titled “Scheduled handlers”@bot.cron() handlers are not interaction-driven, so there is no payload to build for them. Call the registered coroutine directly:
def test_daily_rewards(): run(bot.crons["daily_rewards"]["handler"]())Or use bot.run_cron("name"), the synchronous equivalent cordless cron and the deployed worker use, it wraps the call in asyncio.run() itself, so call it directly rather than wrapping it in run() again, asyncio.run() cannot be called from within an already-running event loop.
