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 | suprnova::queue::testing::install_fake() |
free functions in queue::testing |
guard holds process-wide serializer | queues.md |
| Bus | suprnova::bus::testing::install_fake() |
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 ;
let _guard = install_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.
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 FAKE_SERIAL
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.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.
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::testing::install_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) |
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 timestamp
Every Queue::push, Queue::push_later, Queue::later,
Queue::push_unique*, and the chain/batch dispatchers all funnel
into the same recorder. See Queues for push_unique
semantics under the fake (it always records and reports "pushed").
Bus - bus::testing::install_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.
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
