navree

Database tests

A fresh SQLite database per test, preparing data with factories, seeders and the API, and asserting on rows, change events and audit entries.

On this page

Most of what an API does ends up in the database, so most tests check it there: arrange some rows, call the API, then look at what it stored. Navree gives every test its own database, so tests never see each other's rows and need no cleanup.

A database for every test

TestApp::builder().temp_database() points the app at a new SQLite file in a temporary directory and turns on database.migrate_on_boot. For an app you boot with from_builder, temp_sqlite_url() gives you the URL, and you set database.migrate_on_boot yourself (booting your whole app). Every test starts from an empty database with every module's migrations applied, and the file is deleted with the app:

Rust

There is no equivalent of Laravel's RefreshDatabase trait to remember, and tests aren't wrapped in a transaction that rolls back at the end. Each request commits or rolls back as it would in production, so a test can check what was committed, after-commit hooks included.

These databases are SQLite only. There is no PostgreSQL counterpart: temp_database() always creates a SQLite file, and nothing creates a PostgreSQL database per test.

To reach the database in a test, resolve Db from the app and query the pool with db.conn():

Rust

Preparing data

With factories

Factories insert records with defaults you override where the test cares:

Rust

Factories write directly, without your services' rules and without change events. That makes them quick for the data a test merely needs, but not for the behaviour under test.

With seeders

Run your seeders as the db:seed command would:

Rust

or call the functions your seeders use. The CRM's test kit seeds its roles and demo accounts with ensure_demo_accounts(app.ctx()), the same function its crm-demo seeder calls.

Through the API

Creating data through your own endpoints exercises the most code, and stays correct when your rules change. The CRM's tests keep small helpers for it:

tests/api/support.rs

For records another module owns, whose API isn't part of the test app, the CRM inserts the row directly with SeaORM, as insert_deal in the same file does.

Asserting on the database

After a request, query the table with SeaORM and check what's there:

Rust

Counting is often enough, and it is the way to check that a rejected request left nothing behind:

Rust

Checking the row also proves what the API can't show. A soft-deleted company answers 404, but its row must still be there:

Rust

Change events

To test what a service records for the audit trail, run it in a unit of work of your own and read uow.changes() before rolling back:

Rust

Audit entries

With AuditModule in the app, the change observer writes data entries inside each request's transaction. Security entries (sign-ins, denials) go through a background writer, so call flush() before you read them. The CRM reads them through AuditLog::list:

Rust

See the audit log for the filters and the entry fields.

Time

Rows store timestamps, and a test that compares them with Utc::now() is fragile. Inject the app's dyn Clock in your services and give the test app a FixedClock; then created_at is exactly the time you set, and clock.advance(..) moves it. See the clock.

Commands and migrations

app.run_command runs any registered command against the test's database. Rolling all migrations back and running them again checks your down methods:

Rust

Catching pool starvation

On SQLite, a request that holds its unit of work's connection and then waits for a second one can starve the pool under load (SQLite and concurrency). The CRM catches such code with a pool of one connection and a short timeout, where any request needing a second connection fails at once:

Rust

Its test then calls every route that combines a record check, CurrentUser and embedded users, and asserts they all succeed quickly. Tests that send many requests at once need real threads: #[tokio::test(flavor = "multi_thread", worker_threads = 4)].

Cleaning up

Dropping a TestApp deletes its database directory. On Windows, a pool that is still open keeps the files, so end tests with app.shutdown().await (or the CRM's crm.shutdown().await), which closes the pool first.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks