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::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::dispatchfor an unregistered command, the fake still returnsErr(_)- 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 = fake;
to
.send
.await?;
fake.assert_sent_count;
fake.assert_sent;
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 Queue;
use ;
let _guard = fake;
schedule_welcome_email.await?;
;
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 ;
use JobQueued;
use pushed_with_id;
let _queue = fake;
let _events = fake;
push.await?;
let = .remove;
assert_eq!;
assert_eq!;
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 ;
fake
.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 ;
use DiskAssertExt;
let _guard = fake;
let disk = disk?;
disk.put.await?;
disk.assert_exists.await;
disk.assert_count.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;
use ;
async
| 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 ;
async
| 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 Queue;
use ;
async
| 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 ofJpushed_with_available_at::<J>() -> Vec<(J, DateTime<Utc>)>- same, with each job's scheduled timestamppushed_with_overrides::<J>() -> Vec<(J, EnvelopeOverrides)>- same, with each job's declared per-push overridesbatched() -> Vec<FakedBatch>- every recorded batch, in dispatch order. AFakedBatchhasid,nameandjobs, andjobs_of::<J>()decodes the jobs of one type.chained() -> Vec<FakedChain>- every recorded chain, in dispatch order. AFakedChainhaslinks,job_names()andlink::<J>(index), which decodes the link atindex.
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 Bus;
use ;
async
| 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 EventFacade;
use ;
async
| 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 = fake_only;
// Fake every event EXCEPT these.
let _guard = fake_except;
And one variant suppresses without recording:
muted
.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 ;
use DiskAssertExt;
async
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 ;
async
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 = install;
fake
.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 |
|---|---|---|
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
testingmodule 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 reals3disk 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::fakeis 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!, andTestContainer::fake - HTTP Tests - driving
handle_requestdirectly without opening a socket - Database Tests - the per-test in-memory database story
- Service Container -
TestContainer::fakefor swapping injected services
