The DB-specific companion to Testing. Where that chapter
covers the test harness - #[suprnova_test], describe! / test!,
expect!, and the in-process fakes - this one covers what changes
when your test needs a database: how TestDatabase builds one for
you, how isolation actually works, where factories and seeders plug
in, and when an in-memory SQLite is and isn't enough.
The two constructors
Every database test starts by building a TestDatabase. Two
constructors, two intents.
TestDatabase::fresh::<Migrator>()
Builds an in-memory SQLite database, runs your migrator end-to-end,
and registers the connection in the test container so any code
calling DB::connection() or App::resolve::<DbConnection>()
resolves to it. This is the right default for everything that touches
real schema.
use TestDatabase;
use crateMigrator;
async
Migrator is your application's MigratorTrait implementation -
the same type the production suprnova migrate command runs. By
threading the real migrator through the test schema you make schema
drift impossible: a column the migrator forgot to add cannot be
silently present in the test DB.
The test_database!() macro is sugar for the common case (crate::migrations::Migrator):
use test_database;
async
// Or with a custom migrator path:
let db = test_database!;
TestDatabase::sqlite_memory()
Same container and registry wiring, but does not run any migrator. Use this when the test wants precise column-shape control - typically cast round-trips, query-builder SQL surface tests, or driver-level edge cases where a full migrator is overkill or noise:
let db = sqlite_memory.await.unwrap;
db.execute_unprepared
.await
.unwrap;
// Then write directly and read back with the typed helpers:
let row = db.fetch_one.await.unwrap;
sqlite_memory() is the foundation fresh() is built on - fresh
calls it and then runs your migrator. Anything you can do with
fresh you can do here; you just bring your own DDL.
execute_unprepared, fetch_one, fetch_all
TestDatabase re-exports the three SeaORM execution shapes you reach
for most in tests, so test files don't have to pull in
ConnectionTrait:
| Method | Use for |
|---|---|
execute_unprepared(sql) |
DDL or DML with no placeholders. Returns Result<(), FrameworkError> |
fetch_one(sql, bindings) |
One-row SELECT. Errors if zero rows |
fetch_all(sql, bindings) |
All-row SELECT |
The bindings are Vec<sea_orm::Value> - the same shape the
production query path uses. The connection's backend (SQLite for
both constructors) is supplied for you, so a ? placeholder is
correct.
How isolation actually works
The fresh-database-per-test model is the isolation mechanism. Each
call to fresh() or sqlite_memory() opens a new sqlite::memory:
connection, which under SQLite is an entirely separate database
instance - no shared schema, no shared rows, no other test can see
into it. There is no transaction wrapper, no RefreshDatabase trait
to opt into and no rollback to remember: the next test gets a
clean empty DB because it builds its own.
When the TestDatabase value drops, three things happen, in this
order:
- The held
TestContainerGuardclears the thread-local test container, so any subsequentApp::get::<DbConnection>()no longer finds the test connection. - If this was the last live
TestContainerGuardin the process, the namedConnectionRegistryis wiped. (A refcount overFAKE_GUARDSguarantees an inner test's drop cannot erase a connection name a concurrent outer test still depends on - the standing trap that prompted the refcount.) - The SQLite connection itself drops, which destroys the in-memory database.
Because state is rebuilt rather than rolled back, the isolation is
stronger than BEGIN/ROLLBACK wrapping: there is no committed
state to mistakenly survive, no nested transaction quirks, no
sequence-counter drift between tests. The cost is that you pay for
running the migrator once per test (negligible for SQLite with most
schemas; if it becomes a real cost, see "Sharing a migrated database
across tests" below).
Why the pool is pinned to one connection
Both constructors build the database with max_connections(1) and
min_connections(1). This is load-bearing for sqlite::memory:,
not a generic policy.
sqlite::memory: is a per-connection database - each new
connection in the pool would be a separate, empty SQLite instance.
A pool of size 2 would mean half your queries see the migrated
database and half see an empty one. Pinning the pool to one
connection makes every query in the test land on the same in-memory
database that the migrator ran against.
The consequence: a test that exercises true connection concurrency (two transactions racing, replica routing, a queue worker hitting the DB while a request handler does) needs a real database. See "When SQLite in-memory isn't enough" below.
Factories in tests
Factories produce randomized model instances and (optionally) persist them. The persistence path resolves the bound test connection automatically - there is no factory-side wiring for tests.
use crateUserFactory;
async
Two patterns worth knowing:
Factory inserts bypass model events. The Persistable impl that
backs create() / create_many() writes through SeaORM's
ActiveModelTrait::insert directly - it does not go through the
Model::create surface that dispatches Creating / Created /
Saving / Saved. A test that asserts "no observer fires while we
build the fixture" needs nothing special; a test that asserts "the
Created observer DID fire" must drive Model::create(...) (or
save()) instead of a factory.
create_many does not transact. Inserts are sequential. If a
later row fails the prior rows are not rolled back. Wrap the call
in your own DB::transaction if a test requires atomicity:
DBtransaction.await.unwrap;
See Eloquent → Factories for the full
factory surface (states, sequences, with-relations, count,
times, make_one / create_one).
Seeders in tests
Seeders are functions you've registered with the framework's seeder registry under a stable name. Two patterns for driving them from tests, one for each axis of intent.
Run a single seeder by name
use seed;
use UsersSeeder;
async
Run the full bootstrap seeder set
use serial;
use seed;
async
Two important contract details:
The seeder registry is process-global. seed::register::<S>()
inserts into a RwLock<IndexMap> keyed by S::name(). A test that
mutates the registry should call seed::clear() at entry, register
the seeders it needs, run, and clear() again at exit - and the
test itself should be #[serial_test::serial] so two parallel tests
don't fight over the registry. #[suprnova_test] does not auto-
register seeders; only the explicit seed::register::<>() call in
your own bootstrap.rs or in the test body puts them in the
registry.
Model-driven seeds vs factory-driven seeds. A seeder that loops
User::create(...) in a for fires Creating / Saving /
Created / Saved per row and invokes every registered observer.
For bulk seeding where that fanout is unwanted, wrap the loop in
seed::without_events:
without_events.await?;
The mute is task-scoped - only the work performed inside the
future is silenced; concurrent request handlers and queue workers
continue to fire events normally. Factories (create_many) already
bypass the event path, so without_events is unnecessary around
them.
See Seeding for the seeder authoring surface and Eloquent → Factories for the relationship between the two.
Parallel-safe database tests
cargo test runs tests in parallel by thread. The default
#[suprnova_test] expansion (which is #[tokio::test], i.e. a
current_thread runtime per test) interacts safely with this for
two reasons:
- Each test gets its own
sqlite::memory:connection. Tests do not share DB state. - The bound connection lives in the thread-local
TestContainer. Tests do not share container bindings.
What you don't have to think about: DB::connection(), App::resolve,
factory persistence, model trait writes - these all transparently
land on the right per-test database.
What you do need to think about:
| Surface | Why it's process-global | Mitigation |
|---|---|---|
ConnectionRegistry (DB::register_named, __read_replica__) |
Single RwLock<HashMap> shared by the process |
#[serial_test::serial] for any test that registers or reads named connections |
| The seeder registry | Single RwLock<IndexMap> |
#[serial_test::serial] + seed::clear() at entry and exit |
| The Eloquent observer / scope registries | Keyed by TypeId::<M>() |
Each test should use a unique model struct, or be #[serial] and call the registry's clear() helper |
The named query log (DB::enable_query_log) |
Single process-global ring buffer | #[serial] if assertions read the log |
The connection-registry refcount makes this safer than it sounds: a
test holding a TestContainerGuard keeps the registry alive even
when a sibling test's guard drops. You still want #[serial] for
the tests that actually mutate the registry, so their reads and
writes can't interleave.
Multi-thread runtime caveat
#[suprnova_test] expands to #[tokio::test] with the default
current_thread runtime, so the thread-local container path always
works. If you explicitly opt a test into the multi-thread runtime:
async
Two fixes, depending on what the test does:
-
Direct connection access -
db.conn()still returns the right&DatabaseConnectionregardless of which worker thread reads it. If the test only ever talks to the DB through thedbhandle (not throughDB::connection()), the multi-thread runtime is fine. -
TestContainer::scope- wrap the test body inTestContainer::scope(async { ... }).awaitand bind your fakes (and the DB connection) inside it. The scope binds the container to the task-local layer, which is preserved across awaits even when the runtime hops the future between worker threads. For spawned sub-tasks, useTestContainer::spawn(not baretokio::spawn) so the task-local container is captured and reinstalled inside the spawned future.
See Service Container → Lookup order for the full task-local / thread-local / global layering.
SQLite in-memory vs a real Postgres / MySQL / MariaDB
TestDatabase is intentionally SQLite-only. The driver is hardcoded
to sqlite::memory:; there is no TestDatabase::postgres(),
fresh_with_url(), or env-driven variant. For the overwhelming
majority of test surface - model CRUD, query builder shape, cast
round-trips, relationship loading, observer firing order, soft-delete
semantics - SQLite in-memory is the right tool: zero setup, zero
network, milliseconds per test, perfect isolation, no external
service to keep alive in CI.
There are four cases where SQLite in-memory isn't enough:
- Driver-specific SQL. A query that uses Postgres
LATERAL,JSONBoperators,ON CONFLICT ... WHERE, MySQL window functions, or any other dialect-specific surface won't run on SQLite. The model+builder path tries to stay generic, but a raw-SQL test asserting Postgres-shaped output needs Postgres. - Concurrency under real connection contention. SQLite in-memory is single-connection (see "Why the pool is pinned to one connection"). Tests that race two transactions, exercise read-replica routing under load, or measure deadlock retry need a multi-connection server.
- Vector / NoSQL / temporal surfaces. Suprnova's MariaDB
VECTORdriver, Qdrant integration, Pinecone integration, and similar non-SQL drivers cannot be modelled in SQLite at all. - Production parity smoke tests. A handful of "does this actually work on the real DB we deploy to?" tests, gated to CI, are worth keeping even when the unit-test layer is SQLite.
For all four cases the pattern is the same: step outside
TestDatabase entirely, build a DbConnection against an
operator-supplied DATABASE_URL-style env var, env-gate the test
so it skips when the var is absent, and mark it #[serial] so two
of them don't fight over the shared real database. The
MARIADB_URL pattern in framework/tests/vector_mariadb.rs is the
canonical example:
use serial;
use ;
async
async
The standing convention: name the env var after the target driver
(POSTGRES_TEST_URL, MYSQL_TEST_URL, MARIADB_URL), print a
skip line so a developer running the suite locally sees the test
was skipped (not silently passed), and document the env var in the
test module's leading doc-comment so CI can wire it up.
A worked example
The full app dogfood pattern, combining everything in this chapter:
use Migrator;
use Post;
use User;
use serial;
use TestDatabase;
use ;
async
Step 5 is the part that proves the wiring: the model query and the
raw fetch_one are both reading the same in-memory database - the
model surface because the DB::connection() lookup found the
TestContainer binding, the raw fetch_one because db.conn()
returns that same connection directly.
Cross-references
- Testing - the test harness,
expect!,describe!,test!, fakes. - Database - the surface-level testing
section that introduces
TestDatabase. - Eloquent → Factories - factory definition syntax, states, sequences, relations.
- Seeding - seeder authoring, ordering, idempotency.
- Service Container - task-local vs thread-local
vs global lookup, which decides what
DB::connection()resolves to inside a test. - Mocking & Fakes -
Storage::fake,Mail::fake,Queue::fake,Notification::fake, and the trait-bind pattern for swapping in fake HTTP clients and other external surfaces. - HTTP Tests - driving handlers through the
routing stack with a
TestDatabasebound.
