Manual contentsTestingBrowse 113 chapters
Manual 17 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 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)

The guard is a do-nothing token whose only job is to keep the fake installed; the assertions live in a testing submodule next to the fake's internals. 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 a default in-memory disk, 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_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

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.

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.

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) a push of J declared queue via EnvelopeOverrides
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

The data side returns the typed jobs themselves:

  • pushed::<J>() -> Vec<J> - every captured push of J
  • 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.

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. 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(). assert_pushed_on_queue / assert_pushed_on_connection check the declared override, not a resolved queue or connection name: Queue::route and Job::queue/ Job::connection resolution never run under the fake (there's no driver push to resolve them for), so a job that would fall through to a route or a job-level default in production shows up here with no override at all. Reach for pushed_with_overrides directly to assert anything else the overlay carries - timeout, fail_on_timeout, max_tries, backoff.

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

Under the fake, Bus::dispatch returns Ok(Dispatched::Captured) instead of running the handler. Real failures - encode/decode errors, no handler registered before the fake was installed - still surface as Err(_). See Command Bus.

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 a "default" in-memory disk, 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 a non-default 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