Test databases and factories
TestDb gives each test a database that leaves nothing behind;
Two helpers for tests that touch the database (since 18.29.0, model-* features): TestDb, a database per test that leaves nothing behind, and #[derive(Factory)], which builds #[derive(Model)] structs with a default for every field.
TestDb
use rust_web_server::test_client::TestDb;
#[tokio::test]
async fn notes_start_empty() {
let db = TestDb::sqlite().migrations("migrations").await.unwrap();
db.execute("INSERT INTO notes (text) VALUES ('hi')", &[]).await.unwrap();
// db derefs to DbPool; db.pool() is a clone for the app's state
}TestDb::sqlite() (and the other constructors) return a builder: .migrations(dir) adds a directory of *.sql migrations (call it again for more; relative to the package root under cargo test), and .await — or .build().await — connects and migrates. A failing migration is the Err.
| Backend | Constructor | How the test is isolated |
|---|---|---|
| SQLite | TestDb::sqlite() |
a fresh in-memory database per TestDb, migrations applied; gone with the last clone of its pool. Tests run in parallel |
| PostgreSQL | TestDb::postgres(url), TestDb::postgres_from_env() |
migrations applied for real (once per process and directory); then a pool of one connection that starts a transaction as it connects. Nothing is committed: db.rollback().await rolls it back, and dropping the TestDb closes the connection, which the server rolls back |
| MySQL | TestDb::mysql(url), TestDb::mysql_from_env() |
the same as PostgreSQL |
The _from_env forms read RWS_TEST_POSTGRES_URL / RWS_TEST_MYSQL_URL (sqlx URLs, e.g. postgres://user:password@localhost/app_test). When the variable isn't set they print skipped: … and return None, so a test runs where a server is available and returns early elsewhere:
#[tokio::test(flavor = "multi_thread")]
async fn orders_against_postgres() {
let Some(db) = TestDb::postgres_from_env() else { return };
let db = db.migrations("migrations").await.unwrap();
// …
}Limits of the transaction, the usual ones for transactional tests:
- Give tests a database of their own: the schema (migrations) persists, only data is rolled back.
- One connection: code holding two connections at once (two transactions alive together) waits for itself.
DbPool::begin/transactiondo work — sqlx makes them savepoints inside the test's transaction, so theircommitcommits nothing. - Raw
BEGIN/COMMITstatements end the test's transaction, and so does DDL on MySQL (CREATE,ALTER,DROP,TRUNCATEcommit implicitly). Keep schema changes in migrations. - On PostgreSQL a failed statement aborts the transaction, and every later statement in the test fails; run statements that are expected to fail inside
DbPool::transaction(a savepoint).
An app on a TestDb
Hand db.pool() to the app's state and drive it with TestClient as usual. From #[tokio::test], an App::with_async_state app is best driven with send_async().await, which awaits the handler instead of blocking the test's thread:
use rust_web_server::app::App;
use rust_web_server::test_client::{TestClient, TestDb};
#[tokio::test]
async fn counts_notes() {
let db = TestDb::sqlite().migrations("migrations").await.unwrap();
let client = TestClient::new(my_app::app(db.pool()));
let response = client.get("/notes").send_async().await;
assert_eq!(200, response.status());
}send() works too (the handler runs on rws's long-lived rws-async runtime, so the pool keeps its connection), but it blocks the test's thread while it waits: on the default single-threaded test runtime nothing else runs meanwhile, so a pool whose connections belong to that runtime — PostgreSQL and MySQL, made in the test — would wait for it forever. SQLite connections don't depend on a runtime.
Factories
Add Factory next to Model:
use rust_web_server::{Factory, Model};
#[derive(Model, Factory, Debug, Clone)]
#[table(name = "users")]
pub struct User {
#[primary_key(auto_increment)]
pub id: i64,
pub name: String, // "name-1", "name-2", …
pub email: String, // "email-1@example.com", …
pub age: Option<i32>, // None
pub admin: bool, // false
}User::factory() returns a ModelFactory<User>:
let unsaved = User::factory().build();
let ann = User::factory().with(|u| u.name = "Ann".into()).create(&db).await?;
assert!(ann.id > 0); // read back after the INSERT
let admins = User::factory().with(|u| u.admin = true).create_many(3, &db).await?;
let five = User::factory().build_many(5);| Method | What it does |
|---|---|
.with(|m| …) |
change each built instance (after the defaults and earlier withs) |
.build() / .build_many(n) |
instances, not saved |
.create(&pool).await |
one instance INSERTed (whatever its key) and read back with its id |
.create_many(n, &pool).await |
n of them |
Defaults per field type: numbers 0, bool false, Option None, String the field's name and a sequence number (with @example.com for a field whose name contains email), so unique columns stay unique. An auto_increment key is 0 (the database picks it); any other key is the sequence number. #[ignore] fields, and field types with no FactoryValue impl, get Default::default() — implement test_client::factory::FactoryValue for your own column types to choose their default.
The sequence is per model type and shared by every test in the process, in whatever order they run: assert on the values a factory returns rather than on name-1.
#[derive(Factory)] needs rws-macros 17.30 (the macros feature) and reads the same attributes as #[derive(Model)].