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 build an in-memory SQLite database, with two intents.
Three more run the test on the database DATABASE_URL names; see
Tests on the configured database.
TestDatabase::fresh::<Migrator>()
Builds an in-memory SQLite database, loads your SQLite schema dump
(database/schema/sqlite-schema.sql) when you have one, 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 in-memory constructors) is supplied for you, so a ? placeholder
is correct there.
Tests on the configured database
fresh gives every test a private in-memory SQLite database. When
your suite must run on the engine you deploy to, three constructors
run the test on the database DATABASE_URL names instead. They are
Laravel's three database traits:
| Constructor | Laravel trait | What it does |
|---|---|---|
TestDatabase::refresh::<M>() |
RefreshDatabase |
Migrates the database once per test process, then holds each test in a transaction that it rolls back when the helper drops |
TestDatabase::refresh_lazily::<M>() |
LazilyRefreshDatabase |
Does what refresh does on the first query, so a test that never touches the database never migrates it |
TestDatabase::migrate::<M>() |
DatabaseMigrations |
Runs the migrations with no transaction around the test, and rolls them back when the helper drops |
use TestDatabase;
use crateMigrator;
// DATABASE_URL=postgres://app:secret@127.0.0.1/app_test cargo test
async
DATABASE_URL is required. When it's unset, the three constructors
return an error instead of falling back to the development database
./database.db. When it names an in-memory SQLite database, they
return an error that points you to fresh, which is the in-memory
helper.
How refresh holds a test
The first refresh of a test process for a database and a migrator
migrates that database the way your application's migrate command
does: it loads the schema dump into a database that has run no
migration, then runs the pending migrations. The other refresh calls
of the process don't migrate again. A test that starts while that
migration runs waits for it, so parallel tests share one migration. A
migration that fails is an error of refresh and isn't remembered, so
the next refresh tries again.
Then the helper opens a pool of one connection, begins a transaction
on it, and registers it in the test container. Every query of the test
runs in that transaction: through DB::connection(), a model, a
factory, a seeder, db.conn(), the database session driver, and a
database queue driver. The queue driver takes its connection when it's
built, so build it after the helper. The jobs, failed_jobs, and
sessions rows these drivers write roll back with the test.
A DB::transaction inside the test is a savepoint of the test
transaction. It commits or rolls back its own work, and its
after-commit callbacks, Job::after_commit() pushes included, run
when it commits. When the helper drops, its pool closes and the
database discards the transaction, so a row one test writes is gone
for the next.
Keep these limits in mind:
- The pool holds one connection, as under
fresh. Code that holds a transaction of its own (DB::begin_transaction) and runs a query outside it waits for the connection until the acquire timeout. - A statement that ends the transaction itself defeats the rollback:
DDL on MySQL commits implicitly, and so does a raw
COMMIT. Create tables in migrations, not in the test body. - Test processes that share a Postgres or MySQL database don't see each other's rows, but two tests that insert the same unique key wait for each other. SQLite allows one writer, so tests that share a SQLite file wait for each other's writes, up to the busy timeout. Give parallel test processes their own file.
- A transaction that asks for an isolation level, as the render cache
asks for
REPEATABLE READ, runs at the level of the test transaction, since a savepoint can't change it.
refresh_lazily
refresh_lazily registers its connection at once and does the work of
refresh on the first query, the first transaction, or the first
seeder: it begins the transaction, migrates, and then runs the query.
A migration that fails on that first query can't fail the query from inside the pool. The query runs on the database as it is, which usually fails on a missing table, and dropping the helper fails the test with the migration's error.
migrate
migrate runs your pending migrations on the connection it registers,
each through its up. There's no transaction around the test, so
another connection sees its rows. Use it for a test whose code must see
committed rows from a second connection.
When the helper drops, it rolls back the migrations it ran and no
others, and drops the migration table when it created it, so the
database holds no table the helper created. The next refresh of the
process migrates again.
migrate never loads a schema dump, even into an empty database: a
table that a dump creates has no down to remove it. A migrator whose
migrations you pruned into a dump can't run them, so migrate returns
an error that names the dump. Test that migrator with refresh, which
loads the dump.
When a migration fails, migrate rolls back the migrations that ran
before it and returns the error. When the rollback on drop fails, the
drop panics, so a test can't pass while it leaves its tables behind.
Two migrate tests on one database must not run at once.
Seed before the body
seed::<S>() runs the seeder S on the test database and hands the
helper back, so it chains after any constructor. seed_root() runs
the root seeder, as a bare db:seed does: the seeder registered with
seed::register_root, or every registered seeder in order when there
is no root. seed_root() returns an error when no seeder is
registered, instead of seeding nothing.
let db =
.await
.unwrap
.
.await
.unwrap;
assert_eq!;
S runs whether or not it's registered; the seeders it calls are
found in the registry. Under refresh, the seeded rows belong to the
test transaction and are gone for the next test.
#[suprnova_test]
takes the same choices as keys: refresh, seed, and seed = Path.
Why Suprnova diverges
- Laravel's
RefreshDatabaserunsmigrate:freshonce per process, which drops every table first.refreshruns the pending migrations only. Dropping tables isn't safe when several test processes share one database, and a migrated database stays valid between runs. To start from empty tables, runmigrate:freshon the test database before the run. - Laravel's
DatabaseMigrationsalso runsmigrate:fresh, which loads the schema dump.migrateruns the pending migrations only and never loads the dump, so every table it creates has adownthat removes it when the helper drops. - Laravel's
#[Seed]seeds once per process, insidemigrate:fresh, and the rows stay committed.seedruns in each test, inside its transaction, so a seeder that inserts rows doesn't add them again on every run of a database that keeps its tables. - Laravel keeps one in-memory PDO per process, so
RefreshDatabaseworks with:memory:. A Suprnova in-memory database belongs to one pool, sorefreshneeds a database that outlives a connection, andfreshis the in-memory helper. - Laravel counts the test transaction in
DB::transactionLevel().DB::transaction_level()doesn't, because the test transaction sits below the framework's own transactions.
How isolation actually works
For fresh and sqlite_memory, the fresh-database-per-test model is
the isolation mechanism. Each call 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 and no rollback to remember:
the next test gets a clean empty DB because it builds its own. The
constructors that run on DATABASE_URL isolate tests with a
transaction instead; see
How refresh holds a test.
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 over live guards guarantees 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. For
refreshandrefresh_lazily, this step closes the test's connection and the database discards its transaction; formigrate, it rolls the migrations back and drops the migration table when it created it.
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, refresh migrates a file or
server database once per process - see
Tests on the configured database).
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 fire model events. For a #[suprnova::model]
struct, create() / create_many() take the same insert as
Model::create, which dispatches Creating / Saving / Created /
Saved. A test that asserts "the Created observer fired" can build its
fixture with a factory. To build a fixture that no observer sees, use
create_quietly() / create_many_quietly().
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
The shortest form is seed::<S>() on the test database, which runs
S before the test body; see Seed before the body.
Seeders are also functions you've registered with the framework's
seeder registry under a stable name. Two patterns for driving them
from tests by name, 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. seed::clear() is compiled with the
testing feature, which is a default feature. #[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 fire the same events, so
without_events mutes them as well; create_quietly() and
create_many_quietly() are the shorter form for a factory.
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. Underrefresh, each test gets its own connection and transaction on the shared database instead. - 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. DB::listen callbacks and the
query log belong to the test container too, so a test that counts
queries counts only its own.
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 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
fresh and sqlite_memory are SQLite-only: the driver is
sqlite::memory:. 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, point DATABASE_URL at that engine and build the
test database with refresh, or with migrate when the code under
test must see committed rows from a second connection; see
Tests on the configured database.
For a driver-level test that needs no migrator, or a second database
beside the primary, 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.
MARIADB_URL is also the variable MariaDbVectorDriver::from_env() reads
(with DATABASE_URL as its fallback), and QDRANT_URL is the one
QdrantVectorDriver::from_env() reads. A test that builds either driver
with from_env needs no extra wiring. See Vector.
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. - Migrations - the migrations that
refreshandmigraterun, and the schema dumps thatrefreshloads. - 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,Notify::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.
