This is the hub chapter for Suprnova's testing surface - the macros, the
in-process database, the container fakes, and the encryption key
helpers your test binaries reach for. The depth-first chapters live
alongside it: HTTP Tests for routes + middleware,
Database Tests for everything around
TestDatabase, Mocking and Fakes for the seven external
surfaces (Mail, Notify, Queue, Bus, Events, Storage, HTTP client). Read
this one to learn what's in the box; jump to a sibling when you need
the long form.
The pieces
| Piece | Role |
|---|---|
#[tokio::test] + TestDatabase::fresh::<Migrator>() |
The default workhorse - every real test in the framework uses this |
#[suprnova_test] |
Attribute macro sugar - runs App::init() + App::boot_services() and builds a TestDatabase for you |
describe! + test! |
Jest-shaped grouping macros, paired with expect! for named failure output |
expect! |
Fluent assertion macro with typed matchers (equality, option, result, string, vec, ordering) |
TestDatabase::fresh / sqlite_memory |
In-memory SQLite + container registration, with or without your migrator |
TestContainer::fake / scope / spawn |
Thread-local or task-local DI overrides, hermetic across parallel tests |
install_test_encryption_key[ring] |
Deterministic APP_KEY for tests that touch encrypted casts or signed payloads |
TestClock |
Freeze and move the clock the framework reads, without a sleep - see Moving the clock |
crypto::testing::encrypt_string_under |
A value encrypted under an old key, for a rotation test |
console::test |
Run a console command, read what it printed, and answer what it asks - see Console |
Per-surface fake() helpers |
Mail, Notify, Queue, Bus, Events, Storage, HTTP - see Mocking |
TestResponse |
Fluent assertions over an HTTP test's (status, headers, body) triple - see HTTP Tests |
AssertableInertia |
Fluent assertions over an Inertia page object - see HTTP Tests |
You won't reach for everything in one test. A typical action test uses
the first three; a DI-heavy test adds TestContainer; an HTTP test
swaps TestDatabase for the handle_request pipeline; a payments
test installs the encryption keyring.
The default workhorse
Every real test in the framework looks like this:
use TestDatabase;
use crateMigrator;
async
TestDatabase::fresh::<M>() opens a fresh sqlite::memory: connection,
runs your migrator end-to-end, and registers the connection in the test
container. Any code that calls DB::connection() or
App::resolve::<DbConnection>() afterwards resolves to it - including
the #[suprnova::model] query builder and any service you resolved
out of the container. When the TestDatabase drops, the registration
goes with it.
The test_database!() macro is one-liner sugar for the
crate::migrations::Migrator case:
use test_database;
async
For tests that want precise column-shape control (cast round-trips,
query-builder SQL surface), use TestDatabase::sqlite_memory() -
same container wiring, no migrator. The DDL is yours. See
Database Tests for the full catalogue plus the
execute_unprepared / fetch_one / fetch_all helpers.
#[suprnova_test] - when you want the sugar
#[suprnova_test] is an attribute macro that wraps #[tokio::test],
calls App::init() + App::boot_services() so #[injectable] types
resolve, and binds a fresh TestDatabase. It's optional sugar over
the explicit form above, useful when a test resolves
container-registered services:
use suprnova_test;
use ;
async
If the function takes a TestDatabase parameter (by name), the macro
binds the fresh database to that name. If it doesn't, the database is
still constructed and registered (so DB::connection() works) - it
just isn't bound to a local.
Override the migrator with the migrator = … key:
async
Unknown keys are a compile error (typo migrtor = … won't silently
keep the default migrator).
describe! and test! - when grouping helps
For test files where the same action has many cases, the Jest-shaped
describe! + test! pair gives you nested grouping and named failure
output:
use ;
use crateMigrator;
describe!;
test! accepts three shapes:
// Async test with TestDatabase parameter
test!;
// Async test without database
test!;
// Sync test
test!;
The named-test wrapper threads the test name through the expect!
machinery so a failure surfaces:
Test: "returns all todos"
at src/actions/todo_action.rs:25
expect!(actual).to_equal(expected)
Expected: 2
Received: 0
Without describe!/test! you get the standard panic! output. With
them, the location and human-readable test name lead the message.
expect! - the matcher catalog
expect!(value) returns an Expect<T> wrapper. The matchers are typed
to T - calling to_be_some() on a String is a compile error, not
a runtime panic.
use expect;
// Equality (T: Debug + PartialEq)
expect!.to_equal;
expect!.to_not_equal;
// Boolean
expect!.to_be_true;
expect!.to_be_false;
// Option<T>
expect!.to_be_some;
expect!.to_be_none;
expect!.to_contain_value; // Some(5) check
// Result<T, E>
expect!.to_be_ok;
expect!.to_be_err;
// String / &str
expect!.to_contain;
expect!.to_start_with;
expect!.to_end_with;
expect!.to_have_length;
expect!.to_be_empty;
// Vec<T>
expect!.to_have_length;
expect!.to_contain;
expect!.to_be_empty;
// Ordering (T: Debug + PartialOrd)
expect!.to_be_greater_than;
expect!.to_be_less_than;
expect!.to_be_greater_than_or_equal;
expect!.to_be_less_than_or_equal;
You can use expect! outside test! - the file/line in the failure
message comes from concat!(file!(), ":", line!()). The named-test
header is the only thing the macro doesn't add on its own.
TestContainer - DI fakes that don't bleed
The container chapter covers the three-layer lookup in
detail. For tests, the two entry points are TestContainer::fake()
(thread-local) and TestContainer::scope(…).await (task-local).
Thread-local, the common case
TestContainer::fake() returns a guard. Until the guard drops,
TestContainer::singleton / bind / factory writes land on the
thread-local override layer and shadow the global container:
use Arc;
use App;
use TestContainer;
async
TestDatabase::fresh / sqlite_memory install their own
TestContainer::fake guard internally - you don't stack them unless
you're testing the registry itself.
Task-local, for multi_thread runtimes
The thread-local layer is set on whichever OS thread called fake().
A multi_thread tokio runtime can migrate your future to another
worker thread across an .await, and the override silently disappears.
TestContainer::scope solves that by binding the override to the
future instead:
use TestContainer;
async
tokio::spawn'd sub-tasks do not inherit tokio task-locals; use
TestContainer::spawn instead - it captures the current scope's
container and re-installs it inside the spawned future:
scope
.await;
Why named connections survive parallel tests
The thread-local container is per-test, but Suprnova also has a
process-global ConnectionRegistry keyed by name (__read_replica__,
custom connection labels) that survives a thread-local reset. A naive
Drop impl would clear that registry every time any
TestContainerGuard went away - wiping another concurrent test's
named connection halfway through it running.
So the guards are refcounted process-wide: fake() counts up, each
guard's drop counts down, and only the last guard to drop clears the
named registry. Two parallel tests using
__read_replica__ are safe: whichever guard drops last owns the
clear.
You don't call this from a test - it runs from TestContainerGuard's
Drop. You only need to know it's there if you're debugging a
"named connection vanished mid-test" symptom, which usually means a
sibling test forgot to wait for its own guard to drop first.
Console tests
suprnova::console::test runs a console command through the dispatcher the
console binary uses and returns a ConsoleRun with what the command
printed, what it asked, and how it ended. A command prints with
console::line and asks with console::ask and console::confirm, so the
test can read the output and prepare the answers:
use console;
async
See Console for the questions, the assert methods, and the rules a command follows to be testable.
Encryption key test helpers
Tests that exercise encrypted casts (casts = { secret = AsEncrypted }
on a #[model(...)]), signed payloads, or the keyring's previous-key
fallback need an APP_KEY installed in-process. The framework ships
two test-only helpers under the testing feature:
use install_test_encryption_key;
async
install_test_encryption_key is idempotent - the underlying Crypt
facade is OnceLock-backed, so the second call is a no-op. Most cast
test binaries call it from every test that touches an encrypted cast;
the first wins, the rest are free.
For rotation tests (writes under the old key, reads under the new key), use the keyring variant:
use EncryptionKey;
use install_test_encryption_keyring;
let new = from_base64.unwrap;
let old = from_base64.unwrap;
let installed = install_test_encryption_keyring;
assert!;
The keyring helper returns true only if the call actually installed
the ring (the OnceLock was empty). To build a value that was encrypted
under an old key, use suprnova::crypto::testing::encrypt_string_under
rather than installing twice. It takes the key, the CryptPurpose, and the
plaintext, and returns the wire string. encrypt_string_for_under takes a
context between the purpose and the plaintext, the way
Crypt::encrypt_string_for does.
use encrypt_string_under;
use install_test_encryption_keyring;
use ;
let new = generate;
let old = generate;
install_test_encryption_keyring;
// A value written when `old` was the current key.
let stored = encrypt_string_under.unwrap;
let =
decrypt_string_with_origin.unwrap;
assert_eq!;
assert!;
Both install_test_encryption_key and the keyring helper live in
suprnova::testing. The suprnova::crypto::testing functions are compiled
with the testing feature, so an application that ships without the feature
has none of them. They bypass the production APP_KEY validation path.
Moving the clock
A test of an expiry should not sleep. The framework reads the time through
suprnova::clock::now(): signed URL deadlines, session idle timeouts, due
scheduled tasks, prune cutoffs, queue availability, the timestamps a
#[model] writes and the window of the Redis rate limiter. Under the
testing feature, TestClock moves what clock::now() returns, so a test
jumps to the moment it wants.
tokio::time::pause does not do this. It moves the timers of Tokio and
nothing that reads the wall clock.
use ;
use Request;
use SignatureVerdict;
use ;
use ;
The guard
| Call | Effect |
|---|---|
TestClock::freeze() |
Stops the clock of the current thread at the time of the call |
TestClock::travel_to(at) |
Sets the clock of the current thread to at (a DateTime<Utc>) and stops it there |
guard.advance(by) |
Moves the clock forward by a chrono::Duration, or back for a negative one |
guard.set(at) |
Sets the clock to at |
guard.now() |
The time the clock shows |
Both constructors return a TestClockGuard. Keep it in a binding, because
dropping it at once gives the system clock back.
A clock under a guard stands still until you move it. Two reads in a row return the same time, so "exactly at the deadline" is a state your test can hold.
When the guard drops, the thread gets its previous clock back. That also
happens when the test panics. Guards nest: an inner guard hands back the time
of the outer one when it drops. A guard is not Send, because it belongs to
one thread.
Threads and tasks
A guard holds for the current thread. A #[tokio::test] of the default
current_thread flavour runs the test, and any task it spawns, on that
thread, so a guard works across .await there.
For a multi_thread runtime, or for code that spawns onto other threads,
use TestClock::scope. It sets the clock for one future, across awaits and
worker-thread hops. The closure gets a TestClockHandle with advance,
set and now:
use ;
use TestClock;
async
Outside the scope, the system clock answers again.
Parallel tests
No call moves the clock of the whole process. A guard affects its own thread and a scope affects its own future, so tests that run side by side do not see each other's time.
Your own code
Code of your own that reads the time to make a decision, such as "is this
invite older than seven days", should read suprnova::clock::now() when
its tests need to move that time. A direct chrono::Utc::now() follows the
system clock.
The health endpoint reports the time of the machine and ignores a test clock.
The testing feature
TestClock, TestClockGuard and TestClockHandle exist when the testing
feature is on. It is in the default feature set, so an application that
depends on suprnova with default features has them in its tests. See
The testing feature and production builds
for a build that leaves the feature off. Without the feature,
suprnova::clock::now() is Utc::now() and nothing else.
The testing feature and production builds
suprnova exposes its test helpers (Storage::fake(), TestContainer,
TestDatabase, crypto rotation hooks like _test_install_key) behind a
Cargo feature named testing. The feature is in the default set, so
consuming test suites get them for free:
[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v3.0.0" }
[dev-dependencies]
# `testing` is on transitively via the dependency above - nothing extra.
The hooks are #[doc(hidden)] and prefixed with _test_, so they
aren't reachable from idiomatic application code even when the feature
is on. The load-bearing safeguard is Server::from_config: it
validates APP_KEY on every boot, not only when the keyring is
uninitialized. A pre-installed test key cannot bypass that check -
boot fails fast if APP_KEY is missing or malformed regardless of
whether anything in-process pre-installed a key.
If you prefer the helpers not to be linked into your production
artifact at all (defence in depth), depend on suprnova with default
features off and enable only what you ship:
[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v3.0.0", default-features = false, features = ["..."] }
[dev-dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v3.0.0", features = ["testing", "..."] }
This is a tightening, not a fix - boot validation closes the actual exploit regardless of which posture you pick.
Why Suprnova diverges
Laravel's PHP test harness gets parallel-test isolation almost for free
because the runtime is single-threaded per request and tests fork a
new process per file. The Suprnova test binary is one process running
many #[tokio::test]s on one or more worker threads concurrently. A
single global container would mean one test's fake bleeds into the
next test's lookup the instant they overlap on a worker thread.
That's why TestContainer has both flavours - thread-local for the
common current_thread case, task-local for multi_thread. The
refcounted clear of the process-global
named-connection registry exists for the same reason: shared state that
can't be made per-test must at least know not to wipe itself while
another test is still leaning on it.
The matcher catalogue (expect!) is typed because Rust lets it be.
Jest's expect(x).toBeSome() only knows at runtime whether x is an
Option; Suprnova's Expect<T> knows at compile time, so a wrong
matcher is a build error, not a flaky test.
Where each piece lives
| Piece | Source |
|---|---|
#[suprnova_test] attribute macro |
suprnova-macros/src/suprnova_test.rs |
describe! / test! proc-macros |
suprnova-macros/src/describe.rs, test_macro.rs |
expect! macro + Expect<T> matchers |
framework/src/lib.rs (macro), framework/src/testing/expect.rs (impls) |
TestDatabase::fresh / sqlite_memory / helpers |
framework/src/database/testing.rs |
test_database! macro |
framework/src/database/testing.rs |
TestContainer + TestContainerGuard |
framework/src/container/testing.rs |
install_test_encryption_key[ring] |
framework/src/testing/mod.rs |
clock::now, TestClock, TestClockGuard, TestClockHandle |
framework/src/clock.rs (re-exported from suprnova::testing) |
encrypt_string_under, encrypt_string_for_under |
framework/src/crypto/testing.rs |
console::test, ConsoleRun |
framework/src/console/testing.rs |
| Per-surface fakes (Mail, Notify, Queue, Bus, Events, Storage, HTTP) | per-domain testing submodules - see Mocking |
TestResponse |
framework/src/testing/response.rs |
AssertableInertia, ReloadRequest |
framework/src/testing/inertia.rs |
Running tests
The standard cargo invocations apply:
# Whole workspace
# One crate
# One test by name (substring match)
# With println! and dbg! output
Suprnova doesn't ship its own test runner; the framework integrates with cargo's. Database tests run in parallel by default - the thread-local container and per-test in-memory SQLite are designed for exactly that.
Next
- HTTP Tests - driving the full request pipeline
through
handle_request - Database Tests -
TestDatabase, factories in tests, seeders in tests, parallel-safe DB testing - Mocking and Fakes - the seven external-surface fakes and the patterns they share
- Service Container - the three-layer lookup that
TestContaineroverrides - Error Model -
FrameworkErrorshapes you'll be asserting on
