Manual contentsTestingBrowse 126 chapters
Manual 22 min read

Mocking and Fakes

Every external surface in Suprnova ships with an in-process fake that captures what your code would have sent - mail, notifications, queued jobs, dispatched commands, fired events, written files, outbound HTTP calls - and a matching set of assertions you run after the fact. The shape is always: install the fake, run the code under test, assert what was captured. This chapter is the consolidated overview; each subsystem chapter (Mail, Notifications, Queues, Command Bus, Events, File Storage, HTTP Client) covers its fake in depth.

The seven fakes

Surface Entry point Assertion style Parallel safety Chapter
Mail Mail::fake() → MailFake guard methods on the guard needs #[serial] - global transport, no serializer mail.md
Notifications Notify::fake() → NotifyFakeGuard typed guard methods and free functions in notifications::testing guard holds process-wide serializer notifications.md
Queue Queue::fake() → QueueFakeGuard free functions in queue::testing guard holds process-wide serializer queues.md
Bus Bus::fake() → BusFakeGuard free functions in bus::testing guard holds process-wide serializer bus.md
Events EventFacade::fake() → EventFakeGuard free functions in events guard holds process-wide serializer events.md
Storage Storage::fake() → StorageFakeGuard DiskAssertExt methods on a disk guard holds process-wide serializer filesystem.md
HTTP client Http::fake(|| async { … }).await assert_sent / assert_not_sent task-local - truly concurrent across tests http-client.md

A few invariants hold across all seven:

  • The fake records, the real backend doesn't run. Mail isn't sent, jobs aren't pushed to the driver, handlers don't run, events skip their listeners, HTTP doesn't hit the network, file writes go into a memory disk. The captured side carries enough information to assert what would have happened.
  • The guard is RAII. Dropping the guard restores whatever was in place before (the previous mail transport, a clean storage registry, no recording for events, etc.). Tests don't need a teardown step.
  • The fake doesn't lie about errors. If your code calls Bus::dispatch for an unregistered command, the fake still returns Err(_) - only successful dispatches are captured.

The shapes, and why they differ

Three patterns recur. Knowing which pattern a fake uses tells you whether to import a free function, call a method on the guard, or wrap the test body in a closure.

Guard-with-methods (Mail)

Mail::fake() returns a MailFake whose own methods are the assertions. This is convenient when the asserter is the fake - you already have it bound to a local - but it's the only fake in this shape:

let fake = Mail::fake();
Mail::to("alice@example.org")
    .send(WelcomeEmail { name: "Alice".into() })
    .await?;
fake.assert_sent_count(1);
fake.assert_sent(|m| m.has_to("alice@example.org"));

Guard plus free functions (Notify, Queue, Bus, Events)

You keep the fake installed with its guard and import assertions from the testing submodule. For notifications, you also use typed methods on the guard. Import what you need:

use suprnova::Queue;
use suprnova::queue::testing::{assert_pushed, pushed};

let _guard = Queue::fake();
schedule_welcome_email(user_id).await?;
assert_pushed::<WelcomeJob>(|j| j.user_id == user_id);

This is the most common shape because it generalises cleanly across types - every assertion is generic over J: Job / C: Command / E: Event instead of being baked into a guard type. The trade-off is one extra import.

Every captured push carries the envelope id the fake assigned, so a test can join what it captured to what a listener saw:

use suprnova::events::{EventFacade, dispatched};
use suprnova::queue::events::JobQueued;
use suprnova::queue::testing::pushed_with_id;

let _queue = Queue::fake();
let _events = EventFacade::fake();

Queue::push(SendInvoice { order_id: 7 }).await?;

let (job, id) = pushed_with_id::<SendInvoice>().remove(0);
assert_eq!(job.order_id, 7);
assert_eq!(dispatched::<JobQueued>(|_| true)[0].id, id);

Under the fake there is no driver, so the fake itself emits the JobQueueing / JobQueued pair a real push would - with the id it recorded. bulk and push_unique emit neither event on the real path, so the fake doesn't emit them either.

Scope-with-closure (HTTP)

Http::fake is the odd one out. Outbound HTTP runs on whatever Tokio task happens to be alive, so the fake state lives in a tokio::task_local!. You can't install it once and let it ride - you have to wrap the body that calls the client:

use suprnova::{Http, fake_response, assert_sent};

Http::fake(|| async {
    fake_response("POST", "/api/users", 201, serde_json::json!({"id": 1}));

    let resp = Http::post("https://example.com/api/users")
        .json(&serde_json::json!({"name": "Ada"}))
        .send()
        .await?;

    assert_eq!(resp.status(), 201);
    assert_sent(|r| r.method == "POST" && r.url.contains("/api/users"));
})
.await;

The payoff: every other fake holds a process-wide serializer so parallel tests run one-at-a-time, but Http::fake is truly concurrent - every test gets its own task-local recorder and they never collide.

Storage's extension trait

Storage::fake() returns a guard and an in-memory disk named "default", but its assertions hang off the disk itself through the DiskAssertExt extension trait:

use suprnova::{Storage, DiskExt};
use suprnova::filesystem::testing::DiskAssertExt;

let _guard = Storage::fake();
let disk = Storage::disk("default")?;

disk.put("invoices/42.pdf", b"...").await?;
disk.assert_exists("invoices/42.pdf").await;
disk.assert_count("invoices/", 1, false).await;

The extension trait is gated on #[cfg(any(test, feature = "testing"))] so production code can't accidentally call disk.assert_exists(…).

Parallel safety, in one paragraph

Six of the seven fakes guard a process-global static. Each one's guard, on construction, takes a dedicated process-wide std::sync::Mutex and holds it until drop. The effect is that any two #[tokio::test]s that install the same fake run serialized under one process - no need for #[serial] from the serial_test crate. Mail is the exception: the MailFake guard swaps the global TRANSPORT without taking a serializer, so concurrent Mail::fake() tests would clobber each other. Mark them #[serial]. Http::fake is also an exception: it's task-local, not process-global, so tests genuinely run in parallel and never need #[serial].

If you interleave real-dispatch with fake-dispatch for the same surface inside one test binary, the real path doesn't take the serializer, so it can race a parallel faked test. Mark the real-dispatch tests #[serial] in that case - the per-chapter docs call this out where it applies (see Command Bus for the canonical example).

Mail - Mail::fake()

use serial_test::serial;
use suprnova::mail::{Mail, Address};

#[tokio::test]
#[serial]
async fn welcome_email_is_sent() {
    let fake = Mail::fake();

    register_user("alice@example.org").await.unwrap();

    fake.assert_sent_count(1);
    fake.assert_sent(|m| m.has_to("alice@example.org"));
    fake.assert_sent(|m| m.subject.starts_with("Welcome"));
    fake.assert_not_sent_to("eve@example.org");
}
Assertion Asserts…
fake.assert_sent(|m| pred) at least one captured message matches
fake.assert_sent_to("…") at least one captured message was routed to email
fake.assert_not_sent(|m| pred) no captured message matches
fake.assert_not_sent_to("…") no captured message went to email
fake.assert_sent_count(n) exactly n captured messages
fake.assert_nothing_sent() nothing was captured
fake.assert_queued("MailableName") at least one queued mailable of this name
fake.assert_queued_with(name, |q| …) a queued mailable matches the predicate
fake.assert_queued_to("…") a queued mailable was routed to email
fake.assert_not_queued("MailableName") no queued mailable of this name
fake.assert_queued_mailable::<M>(|m| pred) a queued mailable of type M matches
fake.assert_not_queued_mailable::<M>(|m| pred) no queued mailable of type M matches
fake.assert_queued_count(n) exactly n queued mailables
fake.queued_on("…") queued mailables routed to a queue
fake.assert_queued_on(name, "…") a queued mailable of this name routed to a queue
fake.queued_on_connection("…") queued mailables routed to a connection
fake.assert_queued_on_connection(name, "…") a queued mailable of this name routed to a connection
fake.assert_nothing_queued() nothing was queued
fake.assert_outgoing_count(n) sent + queued totals n
fake.assert_nothing_outgoing() nothing was sent and nothing was queued

Three answers return a bool instead of panicking, as Laravel's MailFake::hasSent and hasQueued do:

Answer True when…
fake.has_sent("MailableName") a mailable of this name was sent; queued ones don't count
fake.has_sent_mailable::<M>() a mailable of type M was sent
fake.has_queued("MailableName") a mailable of this name was queued

A sent message checks its attachments by content too: m.has_equivalent_attachment(&attachment) compares the name, bytes, and content type, and m.has_attached_data(bytes, name, content_type) checks raw data. m.has_attachment(name) matches the name only.

fake.captured(), fake.queued(), fake.sent(pred), fake.sent_to(…), fake.queued_named(…), and fake.queued_to(…) return the matching data so you can build custom assertions. See Mail for the full surface, including how Mail::queue is mirrored into the fake even when Queue::fake isn't installed.

queued_on_connection / assert_queued_on_connection read QueuedSnapshot::connection - the .on_connection(...) override, if any - the same field Queue::fake's assert_pushed_on_connection reads on the plain-job path below, so the two fakes stay symmetric.

You read queued mailables back as their own type, as Laravel's MailFake::queued does with a class and a closure. fake.queued_of::<M>() rebuilds every queued mailable of type M from its payload, in queue order, and fake.queued_where::<M>(|m| pred) returns the ones the filter accepts:

let fake = Mail::fake();
Mail::queue(Welcome { name: "Ada".into() }).await?;

let queued: Vec<Welcome> = fake.queued_of::<Welcome>();
assert_eq!(queued[0].name, "Ada");
fake.assert_queued_mailable::<Welcome>(|m| m.name == "Ada");
fake.assert_not_queued_mailable::<Welcome>(|m| m.name == "Eve");

A payload that doesn't rebuild as M fails the call with a message that names the mailable and the decode error. It isn't left out, so assert_not_queued_mailable never passes over a mailable it can't read. fake.try_queued_of::<M>() and fake.try_queued_where::<M>(...) return that error instead of panicking. fake.queued() still returns the QueuedSnapshot list.

Why Suprnova diverges

Laravel's assertQueued and assertNotQueued take a class name, a closure, or both. Rust has no overloading, and assert_queued(name) and assert_not_queued(name) keep the signature they shipped with. The typed forms are assert_queued_mailable::<M> and assert_not_queued_mailable::<M>, as has_sent_mailable::<M> sits beside has_sent.

Notifications - Notify::fake()

use suprnova::notifications::{Notify, testing};

#[tokio::test]
async fn order_shipped_notifies_customer() {
    let _guard = Notify::fake();

    ship_order(order_id).await.unwrap();

    testing::assert_sent_to("alice@example.org", "OrderShipped");
    testing::assert_sent_to_on("alice@example.org", "mail", "OrderShipped");
    testing::assert_sent_times("OrderShipped", 1);
}
Assertion Asserts…
assert_sent(|r| pred) at least one dispatched notification matches
assert_sent_to(route, "Name") named notification went to this per-channel route
assert_sent_to_on(route, channel, "Name") dispatched on this channel to this route
assert_sent_named("Name") named notification dispatched on any channel
assert_sent_times("Name", n) exactly n of the named notification
assert_nothing_sent() no notifications dispatched
assert_count(n) exactly n total across all types and channels
assert_nothing_sent_to(route) nothing dispatched to this route

testing::recorded() returns every FakeRecord (notification name, channel, route, JSON data) for finer-grained assertions. Notification recipients are keyed on the per-channel route_for value, so assert_sent_to takes the route string (an email address for "mail", the id-as-string for "database", …) - see Notifications for the routing model.

You inspect typed notifications through the guard without importing an assertion function:

let fake = Notify::fake();
ship_order(order_id).await?;
let sent = fake.sent::<OrderShipped>("alice@example.org", |n| n.order_id == order_id)?;
fake.assert_sent_to::<OrderShipped>("alice@example.org", |n| n.order_id == order_id);
fake.assert_not_sent_to::<OrderShipped>("bob@example.org");

You filter the full notification by concrete Rust type and route. You receive one match per dispatch, even when multiple channels use that route. You keep the untyped testing::assert_sent_to(route, "Name") and its siblings for existing tests.

Queue - Queue::fake()

use suprnova::Queue;
use suprnova::queue::testing::{assert_pushed, assert_pushed_later, pushed};

#[tokio::test]
async fn order_placed_enqueues_charge() {
    let _guard = Queue::fake();

    place_order(42).await.unwrap();

    assert_pushed::<ChargeCustomerJob>(|j| j.order_id == 42);
}
Assertion Asserts…
assert_pushed::<J>(|j| pred) at least one push of J matches
assert_pushed_later::<J>(|j, at| pred) a push of J was scheduled at at (delayed dispatch)
assert_pushed_on_queue::<J>(queue, |j| pred) one push has both the queue and matching job data
assert_not_pushed::<J>(|j| pred) no push of J matches
assert_pushed_on_connection::<J>(connection) a push of J declared connection via EnvelopeOverrides
assert_batched(|batch| pred) at least one recorded batch matches
assert_batch_count(n) exactly n batches were recorded
assert_nothing_batched() no batch was recorded
assert_chained(&["JobA", "JobB"]) a recorded chain is made of exactly these Job::job_name()s, head first
assert_nothing_chained() no chain was recorded
assert_pushed_without_chain::<J>(|j| pred) a typed push of J matches and carries no chain

The data side returns the typed jobs themselves:

  • pushed::<J>() -> Vec<J> - every captured push of J. A payload that doesn't decode as J fails the call with a message that names the job and the decode error, instead of being left out. try_pushed::<J>() returns that error.
  • reserved::<J>() -> Vec<J> - every reservation of J a worker made through fake.driver(), one entry per reservation. try_reserved::<J>() returns the decode error, as try_pushed does.
  • pushed_with_available_at::<J>() -> Vec<(J, DateTime<Utc>)> - same, with each job's scheduled timestamp
  • pushed_with_overrides::<J>() -> Vec<(J, EnvelopeOverrides)> - same, with each job's declared per-push overrides
  • batched() -> Vec<FakedBatch> - every recorded batch, in dispatch order. A FakedBatch has id, name and jobs, and jobs_of::<J>() decodes the jobs of one type.
  • chained() -> Vec<FakedChain> - every recorded chain, in dispatch order. A FakedChain has links, job_names() and link::<J>(index), which decodes the link at index.
  • raw_pushes() -> Vec<RawPush> - every Queue::push_raw, in push order. A RawPush has the payload and queue as they were passed, and envelope() decodes the payload.
  • pushed_raw(|raw| pred) -> Vec<RawPush> - the raw pushes pred accepts.

Every Queue::push, Queue::push_later, Queue::later, Queue::push_unique*, Queue::batch().dispatch(), Queue::chain().dispatch(), Queue::retry_failed and Queue::retry_all_failed funnel into the same recorder, and none of them writes to a driver. No driver has to be installed. Queue::push_raw is recorded as well, apart from the typed pushes. A batch, a chain and a retried job also record as pushes, so assert_pushed sees the jobs of a batch, the head of a chain and every retried job. A chain records only its head as a push, because the links after it have no envelope until the link before them completes. See Queues for the details, and for push_unique semantics under the fake (it always records and reports "pushed").

Only Queue::push_with and Queue::later_with carry an EnvelopeOverrides, so pushed_with_overrides records EnvelopeOverrides::default() for every other entry point - a plain Queue::push reads under the fake exactly as "no override was declared," the same as it would if you asserted entries[0].1 == EnvelopeOverrides::default(). You use assert_pushed_on_queue to match both a recorded queue and its typed payload. You match the override when one was supplied, otherwise the job's Job::queue declaration. You use assert_pushed_on_connection to check the declared connection override. You use pushed_with_overrides for the rest of the overlay: timeout, fail_on_timeout, max_tries and backoff.

One variant lets some jobs through:

// Fake every job EXCEPT these - they reach the real queue.
let _guard = Queue::fake_except(&["ProvisionAccount"]);

// The same, on the guard. A second `except` adds to the list.
let _guard = Queue::fake().except(&["ProvisionAccount"]);

An excepted job is pushed exactly as it is without the fake, so install the driver it should reach first, for example SyncQueueDriver to run it inline. See Queues for how batches, chains and raw pushes behave under except.

To run the recorded jobs, pass fake.driver() to a worker. The fake records each reservation the worker makes, and Queue::reserved_jobs answers from those records instead of reading a driver:

use suprnova::queue::testing::reserved;
use suprnova::{Queue, WorkerControls, run_worker_with_controls};

let fake = Queue::fake();
place_order(42).await?;

let controls = WorkerControls { stop_when_empty: true, ..Default::default() };
run_worker_with_controls(fake.driver(), Default::default(), controls, Default::default()).await?;

assert_eq!(reserved::<ChargeCustomerJob>()[0].order_id, 42);
assert_eq!(Queue::reserved_jobs(None).await?.len(), 1);

See Queues for what the driver does.

Bus - Bus::fake()

use suprnova::Bus;
use suprnova::bus::testing::{
    assert_dispatched, assert_dispatched_times,
    assert_not_dispatched, assert_nothing_dispatched,
};

#[tokio::test]
async fn order_placed_dispatches_charge() {
    let _guard = Bus::fake();

    place_order(42).await.unwrap();

    assert_dispatched::<ChargeCustomer>(|c| c.customer_id == 42);
    assert_dispatched_times::<ChargeCustomer>(|_| true, 1);
    assert_not_dispatched::<RefundCustomer>(|_| true);
}
Assertion Asserts…
assert_dispatched::<C>(|c| pred) at least one dispatched command of C matches
assert_not_dispatched::<C>(|c| pred) no dispatched command of C matches
assert_dispatched_times::<C>(|c| pred, n) exactly n dispatched commands of C match
assert_nothing_dispatched() zero commands of any type dispatched under the active fake

You inspect captured commands with dispatched::<C>(), dispatched_sync::<C>() and dispatched_after_response::<C>() in bus::testing or at the crate root. You receive a Vec<C> in dispatch order for that mode alone, which you filter with its iterator. You receive an empty vector when that type was not dispatched.

You call Bus::dispatch_sync(command).await for the separate synchronous mode. Under the fake, you receive Dispatched::Captured from ordinary or synchronous dispatch. You capture commands without registering a handler. You still receive an error when command serialization fails.

You call Bus::dispatch_after_response(command)? inside your HTTP handler when the command should run after its response is sent. You register its handler at boot as you do for ordinary dispatch. You wait for the whole body and a successful socket flush before its handler starts, including streamed, empty and HEAD responses. You get a logged handler error after the response is sent; you cannot change that response. You discard the commands when writing the response fails. Under the fake, you capture the command at once and run no handler, so you need no HTTP request to assert its capture.

You use after_response_connection(io, |io| async move { ... }) if you embed server::handle_request in your own Hyper loop. You pass the wrapped IO to TokioIo::new and build serve_connection inside the closure. You receive an error from real deferred dispatch outside an active request or without that writer hook. You use Server to install it automatically.

Bus::fake() and bus::testing::install_fake() are the same call and return the same BusFakeGuard. The same holds for Queue::fake() and queue::testing::install_fake().

Events - EventFacade::fake()

use suprnova::EventFacade;
use suprnova::events::{
    assert_dispatched, assert_dispatched_once, assert_dispatched_times,
    assert_not_dispatched, assert_nothing_dispatched, dispatched,
    dispatched_count, dispatched_events, has_dispatched,
};

#[tokio::test]
async fn registration_dispatches_welcome_event() {
    let _guard = EventFacade::fake();

    register_user("ada@example.com").await.unwrap();

    assert_dispatched_once::<UserRegistered>();
    assert_dispatched::<UserRegistered>(|e| e.email == "ada@example.com");
}
Assertion Asserts…
assert_dispatched::<E>(|e| pred) at least one dispatched E matches
assert_dispatched_once::<E>() exactly one E was dispatched
assert_dispatched_times::<E>(n) exactly n of E were dispatched
assert_not_dispatched::<E>(|e| ..) no matching E was dispatched
assert_nothing_dispatched() no events of any type dispatched
assert_listening::<E, L>() listener L is registered for E
has_dispatched::<E>() bool: any E recorded
dispatched::<E>(|e| pred) Vec<E> clones of matching events
dispatched_count::<E>(|e| pred) count of matching events
dispatched_events() HashMap<&'static str, usize> of all dispatches

Two variants narrow what's faked:

// Only fake these - everything else dispatches normally.
let _guard = EventFacade::fake_only(&["UserRegistered", "UserDeleted"]);

// Fake every event EXCEPT these.
let _guard = EventFacade::fake_except(&["TelemetryEvent"]);

And one variant suppresses without recording:

EventFacade::muted(async {
    // No listeners fire, no events recorded.
    run_bulk_import().await;
})
.await;

muted does NOT acquire the serializer, so muted scopes can run in parallel. See Events for the full machinery, including assert_listening (which observes listener registrations that happen inside the fake's scope only).

Storage - Storage::fake()

use suprnova::{Storage, DiskExt};
use suprnova::filesystem::testing::DiskAssertExt;

#[tokio::test]
async fn invoice_upload_persists() {
    let _guard = Storage::fake();
    let disk = Storage::disk("default").unwrap();

    upload_invoice(b"%PDF-1.7 …").await.unwrap();

    disk.assert_exists("invoices/2026/05/30/inv-00042.pdf").await;
    disk.assert_contents("invoices/2026/05/30/inv-00042.pdf", b"%PDF-1.7 …").await;
}

The guard pre-registers an in-memory disk named "default", so trivial tests don't need any disk setup. Register additional disks under custom names with Storage::register_memory("audit_logs") from inside the test if the code under test reaches for another disk.

The disk named "default" is not the default disk. The guard clears a default disk set in code, and FILESYSTEM_DISK stays as the environment has it. If the code under test calls Storage::default_disk(), name the fake disk in the test with Storage::set_default_disk("default"), which wins over FILESYSTEM_DISK.

Assertion Asserts…
disk.assert_exists(path).await the path exists
disk.assert_contents(path, &expected).await the file matches expected byte-for-byte
disk.assert_missing(path).await the path does not exist
disk.assert_count(dir, n, recursive).await dir contains exactly n entries
disk.assert_directory_empty(dir).await dir has no entries (recursive)

All five panic on mismatch with the disk path in the message. See File Storage for the Storage facade itself and the driver story (memory / fs / s3 / azblob / gcs).

HTTP client - Http::fake

use suprnova::{Http, fake_response, assert_sent, assert_not_sent};

#[tokio::test]
async fn payment_webhook_is_acked() {
    Http::fake(|| async {
        fake_response("POST", "/v1/charges", 201, serde_json::json!({
            "id": "ch_42",
            "status": "succeeded",
        }));

        let result = charge_card(amount_cents).await;

        assert!(result.is_ok());
        assert_sent(|r| r.method == "POST" && r.url.contains("/v1/charges"));
        assert_not_sent(|r| r.method == "DELETE");
    })
    .await;
}

fake_response(method, url_substring, status, body) queues one canned response. Method "*" matches any method. Each canned entry is consumed on the first matching request; subsequent matching requests either fall through to the next canned entry or return an empty 200 {}.

Helper Purpose
Http::fake(|| async { … }).await install the task-local fake scope
fake_response(method, url_substring, …) queue a canned response
assert_sent(|r| pred) assert at least one recorded request matches
assert_not_sent(|r| pred) assert no recorded request matches

Spawned tasks don't inherit the fake by default

tokio::spawn doesn't carry task-locals into the spawned future, so work that escapes the parent task escapes the fake too. Two tools handle this:

// Belt-and-suspenders: turn every unfaked outbound call into a hard error.
let _guard = suprnova::FailOnRealCallsGuard::install();

Http::fake(|| async {
    fake_response("GET", "/child", 204, serde_json::json!({}));

    // Explicit opt-in: this child sees the parent's fake state.
    let handle = Http::spawn_with_fake_inheritance(async {
        Http::get("https://child.test").send().await
    });

    let response = handle.await.unwrap().unwrap();
    assert_eq!(response.status(), 204);
})
.await;

FailOnRealCallsGuard is RAII - install it at the top of a test and any outbound call that doesn't hit an active fake errors out instead of touching the network. Http::spawn_with_fake_inheritance is the explicit opt-in for tasks that should share the parent's fake state. See HTTP Client for the full discussion.

Broadcasting

WebSocket broadcasting has a parallel test fixture, but its shape differs enough that it lives in its own chapter: RecordingBroadcastHub is a real BroadcastHub that records every published envelope while still delivering to live subscribers. Bind it in place of InMemoryBroadcastHub and call hub.broadcasts() / hub.assert_broadcast(channel, event). See Broadcasting for the broadcasting model and the recording-hub usage.

Where each fake lives

Surface Source Facade re-export
Mail framework/src/mail/mod.rs suprnova::{Mail, MailFake}
Notifications framework/src/notifications/testing.rs suprnova::{Notify, NotifyFakeGuard} + suprnova::notifications::testing::*
Queue framework/src/queue/testing.rs suprnova::queue::testing::*
Bus framework/src/bus/testing.rs suprnova::bus::testing::*
Events framework/src/events/testing.rs suprnova::{EventFacade, EventFakeGuard} + suprnova::events::*
Storage framework/src/filesystem/testing.rs suprnova::{Storage, DiskExt} + suprnova::filesystem::testing::DiskAssertExt
HTTP framework/src/http_client/fake.rs suprnova::{Http, fake_response, assert_sent, assert_not_sent, FailOnRealCallsGuard, RecordedRequest}

The testing and fake modules are gated behind a Cargo feature named testing. It's in the default feature set, so any test that depends on suprnova picks the helpers up for free. The hooks themselves are #[doc(hidden)] where they could be reached accidentally from application code; the load-bearing safeguard is Server::from_config's APP_KEY validation, which runs on every boot regardless of which test helpers are compiled in. See Testing for the production-build story.

Why these shapes, not one shape

A single uniform shape would be neater on the page and worse in practice. Each shape exists because the underlying state has different concurrency semantics:

  • Mail's transport is a global Arc<dyn MailTransport> swapped by the guard. Method assertions on the returned guard tie the asserter to the specific install, which makes it impossible to call assertions when no fake is active.
  • Notify / Queue / Bus / Events assert on heterogeneous typed payloads - every assertion is generic over the event/job/command type. Free functions in a testing module compose with type parameters more cleanly than a hand-written method set on a guard.
  • Storage assertions are per-disk, not per-fake - the same disk.assert_exists(…) works against a faked memory disk or a real s3 disk in an integration suite. Putting them on the disk via an extension trait keeps that symmetry.
  • HTTP has to follow tasks, not the calling stack. Http::fake is the only fake whose scope can't be expressed as a guard - spawn semantics force a closure.

If you ever find yourself reaching for a helper that doesn't exist, read the relevant chapter; the public testing surface is documented exhaustively per subsystem.

Next

  • Testing - the #[suprnova_test] macro, TestDatabase, expect!, and TestContainer::fake
  • HTTP Tests - driving handle_request directly without opening a socket
  • Database Tests - the per-test in-memory database story
  • Service Container - TestContainer::fake for swapping injected services