Testing
Bots built with Composure are tested in plain cargo test, without Discord or a platform: a
Command is a function, an App is a handler that takes a parsed Interaction, and
composure::testing stands in
for the rest. Turn it on for tests only:
[dev-dependencies]
composure = { version = "0.2", features = ["testing"] }
Calling the function
#[command] and the handler macros keep the function as written, so its logic is a unit test
away:
use composure::command;
/// Repeat some text
#[command]
fn echo(#[option("What")] text: &str, #[option("Times")] times: Option<u8>) -> String {
text.repeat(times.unwrap_or(1).into())
}
fn main() {
assert_eq!(echo("ab", Some(2)), "abab");
}
Running an Interaction
testing::answer runs an App the
way an Adapter does: route the Interaction, call the handler, and answer an error the way the
App renders errors (an ephemeral message by default). Build the Interaction with the module’s builders, and
read the response with ResponseExt:
use composure::{
app, command,
testing::{self, ResponseExt, TestPlatform},
};
/// Repeat some text
#[command]
fn echo(#[option("What")] text: &str, #[option("Times")] times: Option<u8>) -> String {
text.repeat(times.unwrap_or(1).into())
}
#[app(handlers(echo))]
pub struct Bot;
fn main() {
let response = testing::answer(
&Bot,
testing::command("echo").option("text", "hi").option("times", 2),
&TestPlatform::new(),
);
assert_eq!(response.text().as_deref(), Some("hihi"));
assert!(!response.is_ephemeral());
// `text` is required: the user gets an ephemeral error
let response = testing::answer(&Bot, testing::command("echo"), &TestPlatform::new());
assert!(response.is_ephemeral());
}
testing::run returns the
handler’s Result instead, to check the error itself. Every builder starts from what Discord
sends: invoked by testing::users::alice() in a DM, unless told otherwise.
| Builder | |
|---|---|
command("name") | .option("text", "hi"), .option("times", 3), .user_option("who", &user), .option_json(..) for channels and roles; .subcommand("ban"), .group("mod") |
user_command("Avatar", &user), message_command("Quote", "text") | Context menu Commands on a user or a message |
autocomplete("name").focused("option", "typed") | The user typing in an Option |
component("custom_id") | A button click; .values(["red"]) for a select |
modal("custom_id") | A submit: .field("title", "Hi"), .select("kind", ["Bug"]), .input(json!(..)) for any other input |
| On every builder | .by(&user), .in_guild(id), .permissions(..), .locale("fr"), .channel(id), .message(json!(..)), .json(|json| ..) for anything else |
Build custom_ids with the handler’s custom_id(..) function, so the test follows the
encoding: testing::component(&counter::custom_id(4)).
A stand-in Platform
Commands taking Ctx<impl Platform> (REST, deferring) or Ctx<impl HasData<..>> (Bot Data) run
against TestPlatform.
It records REST requests in a MockTransport
(answering 200 {} unless told otherwise) and keeps Background Work until
run_background(), so a test checks the immediate response, runs the work, then checks what was
sent:
use composure::{
app, command,
command::{Ctx, Deferred, HandlerError, Platform},
rest::{EditMessage, Method},
testing::{self, ResponseExt, TestPlatform},
};
/// Count to three
#[command]
fn countdown(ctx: Ctx<impl Platform>) -> Deferred {
ctx.defer(async |ctx| {
for n in ["3...", "2...", "1..."] {
let edit = EditMessage { content: Some(n), ..Default::default() };
ctx.client().edit_original(&edit).await?;
}
Ok::<_, HandlerError>("Liftoff!")
})
}
#[app(handlers(countdown))]
pub struct Bot;
fn main() {
let platform = TestPlatform::new();
let response = testing::answer(&Bot, testing::command("countdown"), &platform);
assert_eq!(response.response_type(), 5); // deferred: "thinking..."
assert!(platform.transport().requests().is_empty());
platform.run_background().unwrap();
let transport = platform.transport();
assert_eq!(transport.sent().len(), 4);
assert_eq!(transport.sent()[0].0, Method::Patch);
assert_eq!(transport.body(3)["content"], "Liftoff!");
}
- Answers:
MockTransport::new().respond(404, "..")queues answers in order, and.answering(|request| ..)answers by route, for canned reads or other APIs the bot calls. Pass it in with.with_transport(..). - Bot Data:
.with_data(config)sets it, and.with_bot_token(None)removes the tokenctx.bot()uses. - Your own traits: a bot whose handlers need more from their State (a key-value store, say)
implements its trait on
TestPlatform<YourData>in its own crate. An integration test can’t: the orphan rule forbids it. There, wrap aTestPlatformin a struct of your own that forwardsPlatformandHasDatato it and adds the rest.
Signed requests
adapter::handle_interaction and
handle_event run the whole Adapter pipeline (signature check,
parsing, dispatch, error rendering) without a platform, and return the HTTP status and body an
Adapter would send. A TestKey
stands in for Discord’s key:
use composure::{
adapter::{InteractionOutcome, handle_interaction},
app, command,
testing::{self, TestKey, block_on},
};
/// Ping
#[command]
fn ping() -> &'static str { "Pong" }
#[app(handlers(ping))]
pub struct Bot;
fn main() {
let key = TestKey::default();
let request = key.signed(testing::command("ping").body());
let outcome = block_on(handle_interaction(
&key.public_key(), Some(&request.signature), Some(&request.timestamp), &request.body, &Bot, &(),
));
assert_eq!(outcome.status(), 200);
let forged = block_on(handle_interaction(
&key.public_key(), Some(&request.signature), Some("1700000001"), &request.body, &Bot, &(),
));
assert!(matches!(forged, InteractionOutcome::Unauthorized));
}
request.headers() gives both signature headers, to send to a running server.
Definitions
What Sync would register is available without Discord too:
CommandSetDefinition::definitions on an
App lists every Command Definition with its Scope, ready to compare against a snapshot.
use composure::{app, command, command::CommandSetDefinition};
/// Ping
#[command(scope = "dev")]
fn ping() -> &'static str { "Pong" }
#[app(handlers(ping))]
pub struct Bot;
fn main() {
let definitions = Bot::definitions();
assert_eq!(definitions[0].scope, Some("dev"));
}
The repository’s tests go further:
examples/bot/tests/:offline.rsruns every example Command on aTestPlatform,regression.rsreplays signed fixtures for every Interaction shape, andperf.rsholds each path to an allocation budget.bots/composure/tests/offline.rs: wraps aTestPlatformto add the bot’s own key-value store.