Components & modals¶
Buttons, select menus and modals go through discord.py's real View dispatch and modal
handling. SimCord only lets a user do what they physically could in the client: you can't
click a button that's missing, disabled, or on a message you can't see. Trying to raises a
SetupError against your test setup — surfacing the kind of bug that would otherwise only
show up in production.
Clicking buttons¶
actor.click(message, *, label=… | custom_id=…) clicks a button by its visible label or its
custom_id:
result = await alice.slash(channel, "delete-data")
confirm = await alice.click(result.response.message, label="Confirm")
assert confirm.response.content == "Deleted."
The click runs through your View.button callback for real, returning an
InteractionResult just like a slash command — so you can
assert on confirm.response, confirm.ephemeral, confirm.deferred, and so on.
You can only click what's clickable
- A button that doesn't exist (wrong label/
custom_id) →SetupError. - A disabled button →
SetupError("a real user could not interact with it"). - A button on an ephemeral message not addressed to this user →
SetupError.
These are deliberate: each corresponds to something impossible in the real client, so catching it keeps your test honest.
Selecting in menus¶
actor.select(message, values, *, custom_id=…) chooses one or more values in a string
select menu:
picked = await alice.select(message, ["green"], custom_id="color")
assert picked.response.content == "You picked green"
Selecting a value that isn't an option fails with a SetupError listing the valid options.
For entity selects (user / role / channel / mentionable), pass the handles a real user
could pick instead of strings — actor.select builds the resolved data so the bot's callback
receives real discord.Member / Role / channel objects:
result = await alice.slash(channel, "assign")
await alice.select(result.response.message, [alice, bob], custom_id="who") # UserSelect
await alice.select(result.response.message, [helper_role], custom_id="role") # RoleSelect
await alice.select(result.response.message, [general], custom_id="chan") # ChannelSelect
Passing the wrong handle kind for a select, or more values than its max_values, fails with
a SetupError.
Submitting modals¶
When the bot responds to an interaction with a modal, the result captures it as
result.modal. Fill it in and submit with actor.submit_modal(shown, values), keyed by
each input's custom_id:
shown = await alice.slash(channel, "feedback")
assert shown.modal is not None
submitted = await alice.submit_modal(shown, {"name": "Alice", "comment": "Great bot"})
assert submitted.response.content == "Thanks Alice"
submit_modal dispatches a real MODAL_SUBMIT interaction, so your Modal.on_submit
callback runs and you assert on the returned result the same way as everywhere else.
A full confirm-flow example¶
import discord
async def test_confirm_flow(simcord_env):
guild = simcord_env.create_guild()
channel = guild.create_text_channel("general")
alice = guild.add_member(simcord_env.create_user("alice"))
# The command posts a message with Confirm / Cancel buttons.
started = await alice.slash(channel, "purge")
prompt = started.response.message
assert "Are you sure?" in prompt.content
# Cancel does nothing destructive…
cancelled = await alice.click(prompt, label="Cancel")
assert cancelled.response.content == "Cancelled."
# …and a disabled Confirm after cancel can't be clicked.
with pytest.raises(simcord.SetupError):
await alice.click(prompt, label="Confirm")
Component timeouts¶
A View(timeout=…) fires on_timeout after the timeout elapses. Don't wait in real time —
fast-forward the virtual clock:
await alice.slash(channel, "offer") # posts a View(timeout=60)
await simcord_env.advance_time(60) # instant
assert channel.last_message.components == [] # bot disabled the buttons on timeout
See Time control for the details.
Persistent views across a restart¶
Persistent views (timeout=None with a fixed custom_id, registered with bot.add_view in
setup_hook) must keep working after the bot restarts. env.restart_bot(new_bot) simulates
that: it detaches the current bot, attaches a freshly built one, and replays the world so the
new client repopulates its cache — without rebuilding the guilds, channels or messages.
result = await alice.slash(channel, "panel") # posts a persistent View
panel = result.response.message
await env.restart_bot(create_bot()) # a brand-new instance
clicked = await alice.click(panel, custom_id="panel:refresh")
assert clicked.response.content == "Refreshed" # the new bot re-attached its view
Pass a freshly built client — re-running the same instance would re-execute setup_hook
(reloading extensions). The virtual clock is preserved, so the world's time does not rewind.
Next¶
- Slash commands — invoking commands and the interaction lifecycle.
- Time control — firing view timeouts and cooldowns instantly.
- Recipes — a reusable paginator test, among others.