Manual contentsTestingBrowse 103 chapters
Manual 12 min read

Testing

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
Per-surface fake() helpers Mail, Notify, Queue, Bus, Events, Storage, HTTP - see Mocking

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 suprnova::testing::TestDatabase;
use crate::migrations::Migrator;

#[tokio::test]
async fn create_user_persists_it() {
    let db = TestDatabase::fresh::<Migrator>().await.unwrap();

    let alice = User::create(attrs! {
        name: "Alice",
        email: "alice@example.com",
    })
    .await
    .unwrap();

    assert!(alice.id > 0);

    let row = users::Entity::find_by_id(alice.id)
        .one(db.conn())
        .await
        .unwrap();
    assert!(row.is_some());
}

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 suprnova::test_database;

#[tokio::test]
async fn shortcut() {
    let db = test_database!();         // == TestDatabase::fresh::<crate::migrations::Migrator>()
    // ...
}

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::suprnova_test;
use suprnova::{App, testing::TestDatabase};

#[suprnova_test]
async fn create_user_via_action(db: TestDatabase) {
    let action = App::resolve::<CreateUserAction>().unwrap();
    let user = action.execute("test@example.com").await.unwrap();

    assert_eq!(user.email, "test@example.com");
    assert!(user.id > 0);
}

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:

#[suprnova_test(migrator = my_crate::tests::IsolatedMigrator)]
async fn create_user_with_isolated_schema(db: TestDatabase) {
    // ...
}

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 suprnova::{App, describe, test, expect, testing::TestDatabase};
use crate::migrations::Migrator;

describe!("ListTodosAction", {
    test!("returns empty list when no todos exist", async fn(db: TestDatabase) {
        let todos = App::resolve::<ListTodosAction>().unwrap().execute().await.unwrap();
        expect!(todos).to_be_empty();
    });

    test!("returns all todos", async fn(db: TestDatabase) {
        Todo::create(attrs! { title: "Buy bread" }).await.unwrap();
        Todo::create(attrs! { title: "Walk dog" }).await.unwrap();

        let todos = App::resolve::<ListTodosAction>().unwrap().execute().await.unwrap();
        expect!(todos).to_have_length(2);
    });

    describe!("with pagination", {
        test!("returns first page", async fn(db: TestDatabase) {
            // nested groups compose
        });
    });
});

test! accepts three shapes:

// Async test with TestDatabase parameter
test!("creates a user", async fn(db: TestDatabase) {});

// Async test without database
test!("calculates the right sum", async fn() {});

// Sync test
test!("adds numbers", fn() {});

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 suprnova::expect;

// Equality (T: Debug + PartialEq)
expect!(actual).to_equal(expected);
expect!(actual).to_not_equal(unexpected);

// Boolean
expect!(condition).to_be_true();
expect!(condition).to_be_false();

// Option<T>
expect!(option).to_be_some();
expect!(option).to_be_none();
expect!(option).to_contain_value(5);     // Some(5) check

// Result<T, E>
expect!(result).to_be_ok();
expect!(result).to_be_err();

// String / &str
expect!(s).to_contain("substring");
expect!(s).to_start_with("prefix");
expect!(s).to_end_with("suffix");
expect!(s).to_have_length(10);
expect!(s).to_be_empty();

// Vec<T>
expect!(v).to_have_length(3);
expect!(v).to_contain(&item);
expect!(v).to_be_empty();

// Ordering (T: Debug + PartialOrd)
expect!(10).to_be_greater_than(5);
expect!(5).to_be_less_than(10);
expect!(10).to_be_greater_than_or_equal(10);
expect!(5).to_be_less_than_or_equal(5);

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 std::sync::Arc;
use suprnova::App;
use suprnova::testing::TestContainer;

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

    let fake = Arc::new(FakeEmailGateway::new());
    let probe = Arc::clone(&fake);
    TestContainer::bind::<dyn EmailGateway>(fake);

    place_order(123).await.unwrap();

    assert_eq!(probe.sent_count(), 1);
}

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 suprnova::testing::TestContainer;

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cross_worker_safe() {
    TestContainer::scope(async {
        TestContainer::bind::<dyn HttpClient>(Arc::new(FakeHttpClient::new()));
        do_async_work_that_may_hop_workers().await;
    })
    .await;
}

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:

TestContainer::scope(async {
    TestContainer::bind::<dyn HttpClient>(Arc::new(FakeHttpClient::new()));
    let h = TestContainer::spawn(async {
        App::make::<dyn HttpClient>().unwrap()  // sees the fake
    });
    let _client = h.await.unwrap();
})
.await;

Why there's a FAKE_GUARDS refcount

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 call ConnectionRegistry::clear() every time any TestContainerGuard went away - wiping another concurrent test's named connection halfway through it running.

The fix is a process-wide AtomicUsize (FAKE_GUARDS). fake() increments it; drop decrements; only the transition back to zero 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.

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 suprnova::testing::install_test_encryption_key;

#[tokio::test]
async fn cast_roundtrip() {
    install_test_encryption_key();   // idempotent; deterministic 32-zero-byte key
    let db = TestDatabase::sqlite_memory().await.unwrap();
    // … encrypt + read back …
}

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 suprnova::crypto::EncryptionKey;
use suprnova::testing::install_test_encryption_keyring;

let new = EncryptionKey::from_base64("...").unwrap();
let old = EncryptionKey::from_base64("...").unwrap();
let installed = install_test_encryption_keyring(new, vec![old]);
assert!(installed, "first install wins");

The keyring helper returns true only if the call actually installed the ring (the OnceLock was empty). To mint ciphertext under an arbitrary key for a rotation test, use suprnova::crypto::_test_encrypt_with rather than installing twice.

Both helpers are #[doc(hidden)] at the crypto layer and re-exported under the testing module - they're test-only and bypass the production APP_KEY validation path.

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 = "v1.2.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 = "v1.2.0", default-features = false, features = ["..."] }

[dev-dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.2.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 FAKE_GUARDS clear on the process-global ConnectionRegistry 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 + FAKE_GUARDS framework/src/container/testing.rs
install_test_encryption_key[ring] framework/src/testing/mod.rs
Per-surface fakes (Mail, Notify, Queue, Bus, Events, Storage, HTTP) per-domain testing submodules - see Mocking

Running tests

The standard cargo invocations apply:

# Whole workspace
cargo test --workspace

# One crate
cargo test -p suprnova

# One test by name (substring match)
cargo test create_user_persists_it

# With println! and dbg! output
cargo test -- --nocapture

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 TestContainer overrides
  • Error Model - FrameworkError shapes you'll be asserting on