Testing
Djangors supports two testing paradigms: in-process route testing via djangors-test (TestClient) and real-socket integration testing over localhost TCP sockets.
1. In-Process Testing (TestClient & TestDatabase)
djangors-test provides an in-process client that executes requests directly through Router::handle() without binding network sockets.
In-Process Route Tests
#![allow(unused)]
fn main() {
use djangors_test::TestClient;
use djangors_core::{Router, Response, StatusCode, Request, PathParams, DjangorsError};
use djangors_sessions::Session;
async fn hello_handler(_req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::text(StatusCode::OK, "hello from test"))
}
#[tokio::test]
async fn test_hello_route() {
let router = Router::new().get("/hello", hello_handler);
let client = TestClient::new(router);
client.get("/hello")
.send()
.await
.assert_status(StatusCode::OK)
.assert_contains("hello from test");
}
}
RequestBuilder::with_state & reading TestResponse
Builders carry app state (AppState) into the request, and the resulting TestResponse exposes
the raw status and body for assertions beyond the assert_* helpers:
| Method | Signature | Notes |
|---|---|---|
RequestBuilder::with_state<T>(state) | fn(self, T) -> Self | Attach an AppState value; request handlers see it via req.state::<T>() |
TestResponse::body_str() | fn(&self) -> String | The response body as lossy UTF-8 |
TestResponse::status() | fn(&self) -> StatusCode | The raw HTTP status code |
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode, Router};
use djangors_test::TestClient;
#[derive(Clone, Debug)]
struct Db(u32);
async fn hi_handler(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let db = req.state::<Db>().expect("state attached");
Ok(Response::text(StatusCode::OK, &format!("db={}", db.0)))
}
#[tokio::main]
async fn main() {
let client = TestClient::new(Router::new().get("/hi", hi_handler));
let response = client.get("/hi").with_state(Db(7)).send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body_str(), "db=7");
}
Form & Session Testing
#![allow(unused)]
fn main() {
async fn submit_handler(_: djangors_core::Request, _: djangors_core::PathParams) -> Result<djangors_core::Response, djangors_core::DjangorsError> { Ok(djangors_core::Response::text(djangors_core::StatusCode::CREATED, "")) }
use djangors_sessions::Session;
use djangors_test::TestClient;
use djangors_core::{Router, StatusCode};
#[tokio::test]
async fn test_authenticated_form_submit() {
let router = Router::new().post("/submit", submit_handler);
let client = TestClient::new(router);
let session = Session::new_empty();
session.set("user_id", 42i64);
client.post_form("/submit", &[("title", "Test Item")])
.with_session(session)
.send()
.await
.assert_status(StatusCode::CREATED);
}
}
Database Fixture (TestDatabase)
#![allow(unused)]
fn main() {
use djangors_test::TestDatabase;
#[ignore]
#[tokio::test]
async fn test_database_queries() {
let test_db = TestDatabase::connect().await.unwrap();
let db = test_db.database();
test_db.create_table("CREATE TABLE test_table (id BIGSERIAL PRIMARY KEY)").await.unwrap();
// ... run queries against db ...
test_db.drop_table("test_table").await.unwrap();
}
}
Constructing a TestDatabase
TestDatabase offers several constructors covering the shared vs. isolated database models:
| Method | Signature | Returns |
|---|---|---|
connect() | async fn() -> Result<Self, DbError> | Backend chosen by TEST_BACKEND/DATABASE_URL (shared, no isolation) |
connect_url(url) | async fn(&str) -> Result<Self, DbError> | Connect to an explicit URL (shared) |
new_for_backend(backend) | async fn(TestBackend) -> Result<Self, DbError> | Fresh in-memory SQLite or isolated Postgres for that backend |
isolated() | async fn() -> Result<Self, DbError> | Throwaway database (uniquely named Postgres, or in-memory SQLite) |
isolated_url(url) | async fn(&str) -> Result<Self, DbError> | Same as isolated(), but using url as the base connection |
#![allow(unused)]
fn main() {
use djangors_test::{TestBackend, TestDatabase};
// Non-isolated: run against an existing database (you manage the schema).
let shared = TestDatabase::connect_url("postgres://user:pass@localhost/my_app").await?;
// Isolated: create a uniquely-named throwaway DB, then drop it on cleanup().
let db = TestDatabase::isolated().await?;
// ... create tables, insert fixtures, run queries ...
db.cleanup().await?;
// Explicitly pick a backend regardless of environment.
let sqlite = TestDatabase::new_for_backend(TestBackend::Sqlite).await?;
}
reset, cleanup, load_fixtures
| Method | Signature | Notes |
|---|---|---|
reset(&[tables]) | async fn(&self, &[&str]) -> Result<(), sqlx::Error> | DROP TABLE IF EXISTS each table in order |
cleanup() | async fn(self) -> Result<(), TestError> | Drops the throwaway database (primary cleanup for isolated DBs) |
load_fixtures::<M>(json) | async fn(&self, &str) -> Result<Vec<M>, TestError> | Deserialize JSON as Vec<M> and bulk_create; M: Model + FromRow + DeserializeOwned |
#![allow(unused)]
fn main() {
use djangors_test::TestDatabase;
async fn seed(db: &TestDatabase) {
// Load rows into a freshly-created table from a JSON array.
let items = db.load_fixtures::<MyModel>(r#"[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]"#).await.unwrap();
// Clear tables between tests.
db.reset(&["my_model"]).await.unwrap();
}
}
SQLite in-memory databases need no cleanup() — they vanish with the connection (see
Choosing a Database Backend).
TestBackend
fn main() {
use djangors_test::TestBackend;
let current: TestBackend = TestBackend::current();
// TEST_BACKEND=sqlite => Sqlite
// TEST_BACKEND=postgres => Postgres
// unset: DATABASE_URL set => Postgres
// unset: no DATABASE_URL => Sqlite
match current {
TestBackend::Postgres => println!("postgres"),
TestBackend::Sqlite => println!("sqlite"),
}
}
Cross-process advisory locks
acquire_cross_process_lock coordinates across separate OS processes (e.g. two crates racing to
DROP/CREATE the same table under cargo test --workspace) using a Postgres session-level
advisory lock. Unlike std::sync::Mutex, it spans test binaries:
#![allow(unused)]
fn main() {
use djangors_test::{acquire_cross_process_lock, TestDatabase};
async fn exclusive_setup() {
let db = TestDatabase::isolated().await.unwrap();
let lock = acquire_cross_process_lock(db.database(), "shared-table-setup").await.unwrap();
// ... create/drop the shared table ...
lock.release().await.unwrap(); // explicit release frees the advisory lock promptly
}
| Method | Signature | Notes |
|---|---|---|
acquire_cross_process_lock(db, name) | async fn(&Database, &str) -> Result<CrossProcessLock, sqlx::Error> | Blocks until the named advisory lock is free |
CrossProcessLock::release() | async fn(self) -> Result<(), sqlx::Error> | Releases the lock (pg_advisory_unlock) |
Release is explicit because Rust can’t run async code in Drop; an un-released lock stays held
until its dedicated connection closes (i.e. when the test process exits).
2. Real-Socket Integration Testing
For full-stack integration testing (including Tower middleware layers, real HTTP headers, cookie handling, and graceful shutdown), test suites bind a local TCP socket on port 0.
Real-Socket Pattern (As Used in examples/polls/tests/voting.rs)
#![allow(unused)]
fn main() {
use std::net::SocketAddr;
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tower::ServiceBuilder;
use djangors_core::{Djangors, DjangorsSettings, Router};
use djangors_core::router::RouterService;
use djangors_core::middleware::{csrf_layer, security_headers_layer};
use djangors_sessions::{SessionLayer, SignedCookieStore};
#[ignore]
#[tokio::test]
async fn test_full_stack_socket() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let settings = DjangorsSettings {
host: "127.0.0.1".to_string(),
port: addr.port(),
debug: true,
..Default::default()
};
let router = Router::new();
let router_service = RouterService::new(router, settings.debug);
let service = ServiceBuilder::new()
.layer(security_headers_layer())
.layer(SessionLayer::new(SignedCookieStore::new(b"test-secret-key-32-bytes-minimum!")))
.layer(csrf_layer())
.service(router_service);
let app = Djangors::new(settings, Router::new());
tokio::spawn(async move {
app.serve_service(listener, service).await.unwrap();
});
// Send HTTP requests over real TCP connection
let mut stream = TcpStream::connect(addr).await.unwrap();
stream.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf);
assert!(response.contains("200 OK"));
}
}
Comparison Summary
| Metric | In-Process (TestClient) | Real-Socket Integration |
|---|---|---|
| Speed | Extremely fast (microseconds) | Fast (milliseconds) |
| Network Overhead | Zero (in-memory execution) | Real local TCP loopback socket |
| Middleware Coverage | Handler-level extensions & state | Full Tower layer stack & HTTP protocol |
| Use Case | Unit tests & route handler assertions | Full-stack end-to-end flow validation |
Choosing a Database Backend
The test suite runs against either PostgreSQL or in-memory SQLite. Which one is used is decided by
TEST_BACKEND, falling back to whether DATABASE_URL is set:
| Condition | Backend |
|---|---|
TEST_BACKEND=sqlite | SQLite (in-memory) |
TEST_BACKEND=postgres | PostgreSQL |
neither set, DATABASE_URL present | PostgreSQL |
neither set, no DATABASE_URL | SQLite (in-memory) |
SQLite needs no server and no setup:
# Fast path - no PostgreSQL required. Unset DATABASE_URL so it cannot be picked up.
env -u DATABASE_URL TEST_BACKEND=sqlite cargo test
# PostgreSQL - required before changing any dialect-specific behaviour
export DATABASE_URL="postgres://postgres:postgres@localhost/djangors_test"
cargo test
Each SQLite test gets a fresh sqlite::memory: database, so isolation is automatic — there is no
shared state to clean up and no advisory lock to arbitrate. The pool is deliberately pinned to a
single connection: with a plain sqlite::memory: URL every pooled connection would otherwise be a
separate database, and setup DDL run on one connection would be invisible to the next query.
Why both
SQLite is dramatically faster. Measured on a development machine, djangors-admin’s 32 tests take
0.69s on SQLite against 15.8s on PostgreSQL.
It is not a full substitute. A handful of tests cover behaviour SQLite cannot express — transaction
isolation levels, pg_advisory_lock, SKIP LOCKED queue claiming, and PostgreSQL’s width-strict
integer decoding. Those tests return early when DATABASE_URL is absent, each with a comment
naming the feature they need. They report as passing on SQLite without executing, so a green
SQLite run alone does not prove those paths still work.
Use SQLite for the fast inner development loop, and run against PostgreSQL before changing anything dialect-specific and in CI.
3. Test Conventions
The suite is expected to stay green under both Postgres and SQLite. Conventions below keep it that way.
Where test code lives
Production .rs files contain no test code. Every #[cfg(test)] module lives in a sibling
file:
- For a crate’s
lib.rs, that issrc/tests.rs, declared#[cfg(test)] mod tests;— see the bottom ofcrates/djangors-admin/src/lib.rs. - For any other module, e.g.
src/queryset.rs, it issrc/queryset_tests.rs, declared with#[cfg(test)] #[path = "queryset_tests.rs"] mod tests;— seecrates/djangors-contrib-payments/src/bachs.rs. The#[path]is required: without it Rust looks forsrc/queryset/tests.rs.
This keeps the production files readable, while the tests stay a child module, so they can
still reach private items. That is exactly why they are not in crates/*/tests/ — an integration
test there only sees the crate’s public API, and much of this suite exercises private functions.
Writing tests that pass on both backends
Tests run as cargo test --workspace (Postgres, needs DATABASE_URL) and as
env -u DATABASE_URL TEST_BACKEND=sqlite cargo test --workspace. The rules below keep both green:
Build DDL from the dialect, never as a fixed string
BIGSERIAL and TIMESTAMPTZ are Postgres spellings; SQLite needs INTEGER PRIMARY KEY AUTOINCREMENT. Use db.dialect().auto_pk_type() and db.dialect().timestamp_type():
#![allow(unused)]
fn main() {
let auto_pk = db.dialect().auto_pk_type();
let ts_type = db.dialect().timestamp_type();
db.conn().execute(
&format!("CREATE TABLE widget (id {auto_pk}, created_at {ts_type} NOT NULL)"),
&[],
)
.await
.unwrap();
}
SQLite is permissive enough to accept the Postgres text, so the CREATE TABLE succeeds and the
failure surfaces much later as an insert that returns no row — which is exactly how this bug hid
(see create_table_sql in crates/djangors-contrib-payments/src/transaction_tests.rs).
Quote identifiers through the dialect
db.dialect().quote_ident("user") — user is a reserved word in Postgres but not in SQLite, so
an unquoted column name passes on one backend and is a syntax error on the other:
#![allow(unused)]
fn main() {
let user_col = db.dialect().quote_ident("user");
let create_profile = format!(
"CREATE TABLE profile (id {auto_pk}, {user_col} BIGINT NOT NULL UNIQUE)"
);
}
Never use raw sqlx against Database::pool()
pool() panics outright on a SQLite handle ("Attempted to access Postgres PgPool on a SQLite Database handle"), and $1 placeholders are Postgres-only. Go through the ORM
(Model::objects(), .save(), .count()), which is already dialect-aware.
Never hardcode a connection string
Use djangors_test::TestDatabase, which honours TEST_BACKEND and DATABASE_URL. A hardcoded
postgres://…@localhost/djangors_test ignores both and can only pass against one specific machine.
Use TestDatabase::isolated() when a test creates its own tables
Several tests sharing one table name against a single Postgres database will race on
DROP/CREATE when run in parallel; isolated() gives each test its own database:
#![allow(unused)]
fn main() {
let test_db = djangors_test::TestDatabase::isolated().await.unwrap();
let db = test_db.database();
}
connect() is fine for read-only work (see TestDatabase::connect() vs
TestDatabase::isolated() in crates/djangors-test/src/lib.rs).
Checklist before adding a test
- Put the module in a sibling file (
src/tests.rsorsrc/{name}_tests.rswith#[path]), never in the production.rs. - Build every
CREATE TABLEfromdb.dialect().auto_pk_type()/timestamp_type(), and quote identifiers withquote_ident(). - Go through the ORM; never touch raw
pool()or write$1placeholders by hand. - Obtain the database via
djangors_test::TestDatabase, never a hardcoded URL. - Use
isolated()when the test creates and drops tables;connect()only for read-only work. - Run under both backends (
cargo test --workspaceand the SQLite env-invocation) before relying on it staying green.