Skip to content

Testing

siderite-testkit drives an App in process, without sockets, and provides disposable databases. Add it as a dev-dependency.

use siderite_testkit::TestClient;
let client = TestClient::new(app());
let response = client.get("/users/1").await?;
assert_eq!(response.status, 200);
let user: User = response.json()?;
client.post_json("/users", &NewUser { name: "ann".into() }).await?;

TestClient::new panics on a misconfigured app (duplicate routes, bad paths, OpenAPI failures); try_new returns the error so a test can assert on it. Requests: get, delete, post_json, post_raw(path, content_type, body), and send(Request<Body>) for anything else (siderite_testkit::http is re-exported). A TestResponse has status, headers, body, json::<T>(), text(), and content_type().

TestClient::start(app) also runs the startup hooks, like App::run without a socket; call shutdown() to run the shutdown hooks.

A built client owns an immutable service, so test-only configuration goes on the builder:

let client = TestClient::builder(app())
.override_value(FakeMailer::default())
.override_dependency::<CurrentUser, _, _>(|_head| async {
Ok(CurrentUser::admin())
})
.with_database("default", test_db.db().clone())
.build();

override_dependency and override_value are the App methods described in Dependency injection. with_database calls App::database, so handlers see it through State<Databases>.

use siderite_testkit::TestDatabase;
let db = TestDatabase::sqlite_memory()
.await?
.with_models(&[Author::META, Post::META])
.await?;
Method Does
sqlite_memory() a fresh in-memory SQLite database (feature sqlite, on by default)
from_db(db) wraps any Db, for PostgreSQL or MySQL tests
with_models(&[..]) creates tables, indexes, constraints, and join tables
with_migrations(dir) applies every migration file in dir
with_signals(signals) attaches a signal registry before cloning the handle out
db(), into_db() the Db
isolated(f) runs f(Db) in a transaction that is always rolled back
db.isolated(|tx| async move {
Author { id: 0, name: "ann".into() }.save(&tx).await.unwrap();
assert_eq!(Author::objects(&tx).count().await.unwrap(), 1);
}).await?;
assert_eq!(Author::objects(db.db()).count().await?, 0);

Rules for isolated:

  • Use only the Db given to the closure. An in-memory SQLite database has one connection, and the transaction holds it, so querying the outer handle inside the closure waits forever.
  • Do not keep clones of the transactional Db after the closure returns.
  • A panic in the closure also discards the transaction.

Because every test rolls back, tests can share one database and still run independently. Tests that need commits (for example on_commit hooks) should use a fresh sqlite_memory() each.

The suites for PostgreSQL, MySQL, MongoDB, and Redis are gated: each test skips itself when its URL variable is unset or does not name the right scheme. The repository docker-compose.yml starts all four on offset host ports so they do not collide with local servers.

Terminal window
docker compose up -d --wait
DATABASE_URL=postgres://siderite:siderite@127.0.0.1:55432/siderite \
MYSQL_URL=mysql://root:siderite@127.0.0.1:53306/siderite \
MONGODB_URL='mongodb://127.0.0.1:57017/siderite?directConnection=true' \
REDIS_URL=redis://127.0.0.1:56379/15 \
cargo test --workspace --all-features -- --include-ignored
docker compose down
Variable Service (image) Notes
DATABASE_URL PostgreSQL (postgres:17-alpine) must start with postgres
MYSQL_URL (or DATABASE_URL) MySQL (mysql:8.4) connect as root: the tests create one database per test
MONGODB_URL MongoDB (mongo:8) single-node replica set (rs0) because the tests use transactions
REDIS_URL Redis (redis:7-alpine) must select database 15; tests delete only their own key prefix

The credentials in the compose file are for local testing only.