Rustango docs
← Guides

Testing

Fast, reliable tests need to drive your app the way a client does — without booting a server or touching the network. Rustango's TestClient runs your router in-process: you call client.get("/path"), it routes the request through the real stack (extractors, middleware, handlers) and hands back the response to assert on. Add transaction-rollback isolation for database tests and a set of response assertions, and a whole request flow is testable without a socket.

Testing in Rustango: TestClient wraps your Router and sends in-process requests through the real handler stack; the TestResponse exposes status, text, and JSON to assert on — no socket, no server

New to a term here? router, handler, fixture, rollback — see the glossary.

Source: rustango::test_client (TestClient, TestResponse) — needs the admin feature, because it wraps an axum::Router. rustango::test_assertions (assert_status_2xx, assert_redirects, assert_cookie_set, …) and rustango::test_db (with_rollback) are ungated.

Runnable version: the snippets below are a passing test — testing_doc.rs (cargo test -p rustango --test testing_doc). Nearly every other *_doc.rs in this repo uses TestClient the same way.

Table of contents


Step 1 — Drive your app with TestClient

Wrap any axum::Router in a TestClient and send requests — no socket is bound, no server task spawned. The request flows through your real middleware and handlers:

use rustango::test_client::TestClient;

let client = TestClient::new(app());          // app() returns your Router

let res = client.get("/ping").send().await;   // routed in-process
assert_eq!(res.status, 200);

TestClient has get / post / put / patch / delete / head, each returning a builder you finish with .send().await.


Step 2 — Assert on the response

TestResponse exposes the status and body in whatever shape you need:

let res = client.get("/ping").send().await;

res.status;                 // u16 — e.g. 200
res.text();                 // body as a String
res.header("content-type"); // Option<&str>
// JSON, two ways:
let res = client.post("/echo").json(&json!({ "name": "Ada" })).send().await;
assert_eq!(res.json_value()["name"], "Ada");   // untyped

#[derive(serde::Deserialize)]
struct Out { name: String }
let out: Out = res.json();                       // typed
assert_eq!(out.name, "Ada");

Sending JSON, headers, and bodies

The request builder chains everything before .send():

let res = client
    .post("/api/posts")
    .header("authorization", "Bearer <token>")   // auth, content negotiation, …
    .json(&json!({ "title": "Hello", "body": "..." }))
    .send()
    .await;
assert_eq!(res.status, 201);

Use .body(...) for raw (non-JSON) bodies, and a missing route returns a real 404 — verified in the backing test.


Testing a real API

app() in your tests is just your router. For a DB-backed API, build it exactly as main.rs does but with a test pool — the pattern most *_doc.rs tests use:

async fn app() -> axum::Router {
    let pool = test_pool().await;                 // a sqlite::memory: or test DB pool
    PostViewSet::router("/api/posts", pool)
}

#[tokio::test]
async fn create_then_list() {
    let client = TestClient::new(app().await);
    let created = client.post("/api/posts")
        .json(&json!({ "title": "Hi", "body": "b" }))
        .send().await;
    assert_eq!(created.status, 201);

    let list = client.get("/api/posts").send().await;
    assert!(list.json_value()["results"].is_array());
}

This is the ViewSets test from that guide — the same TestClient.


Database tests with rollback

Tests that write to a database must not leak state into each other. test_db::with_rollback runs your test inside a transaction and rolls it back at the end, so every test starts from the same clean state and nothing persists:

use rustango::test_db::with_rollback;

#[tokio::test]
async fn creating_a_post_persists_it() {
    with_rollback(&pool, |tx| Box::pin(async move {
        // ... insert + assert against `&mut *tx.lock().await?` ...
        // everything here is rolled back when the closure returns
        Ok(())
    })).await.unwrap();
}

The Box::pin is required, not stylistic: the bound is for<'tx> FnOnce(&'tx AtomicTx) -> Pin<Box<dyn Future<…> + Send + 'tx>>, which is how the closure gets to borrow tx across its own await points. An atomic() on the same pool inside the closure is a savepoint and rolls back too; drop the guard before it or a bulk_insert_pool, or they fail with NestedAtomic. The closure returns Result<T, ExecError>, and so does with_rollback — the rollback happens either way, so the unwrap is about your assertions, not about cleanup.

For SQLite, the *_sqlite_live.rs tests throughout this repo use an in-memory database per test instead — also fully isolated, with zero external setup.


Live suites, and why a green run may prove nothing

Tests named *_live.rs talk to a real database. Most need nothing from you; the rest need an environment variable, and when it is missing they do not fail. They return, and the run reports success.

That is deliberate — it keeps cargo test working on a laptop with no server — but it means a passing run is not evidence the suite ran. Worth knowing before you read a green result as coverage.

Which variable each suite wants

VariableSuitesWhat they need
(none)223Nothing — an in-memory or temp-file SQLite. Always run.
DATABASE_URL131A reachable PostgreSQL server.
MYSQL_TEST_URL63A reachable MySQL 8+ server. Not DATABASE_URL.
REDIS_TEST_URL2A reachable Redis.

A suite reading two variables is counted under both, so the column does not sum to the number of files.

The *_tri.rs suites are counted under both server variables. They read no variable themselves — Backend::pool() does the lookup — and they run their SQLite arm with nothing set, so counting them as needing nothing would be technically survivable and practically wrong: the two arms that need a server are the reason those suites exist. Start both servers, or a tri suite reports a healthy pass count having exercised one backend of three.

MySQL is the one that catches people: it reads its own variable, so a shell with only DATABASE_URL set runs the Postgres suites and silently skips every MySQL one.

Run them one at a time

The live suites share one database and most drop or truncate the framework tables in setup, so running them in parallel makes them fight over the catalog. Use the live profile, which sets test-threads = 1:

cargo nextest run --profile live --workspace --all-features

Without it the failures are many and none of them are real: 758 against PostgreSQL, 95 against MySQL, 0 either way with the profile. The tokio::sync::Mutex guards inside those suites do not help, because nextest runs each test in its own process and a mutex in one does not reach another.

This is a workaround, not a fix — see #1624 for per-process databases, which would restore the parallelism.

Telling a skip from a pass

Most skips are a bare early return with no output at all. A minority print a line to stderr first, which cargo test hides unless you ask:

cargo test --test <name> -- --nocapture

The reliable signal is the count. A live suite that reports 0 passed — or far fewer than the file contains — skipped. running 2 tests … 2 passed with no server running means those two tests returned early.

If you want a suite to fail rather than skip when its server is missing, set the variable to a deliberately bad URL: it will then fail at connect, which is a louder and more honest signal than a skip.


Response assertion helpers

For raw axum::Response values (e.g. from tower::oneshot), test_assertions gives ready-made checks for status, redirects and cookies:

use rustango::test_assertions::{assert_status_2xx, assert_redirects, assert_cookie_set};

assert_status_2xx(&res);
assert_redirects(&res, "/login?next=/dashboard");
assert_cookie_set(&res, "rustango_session", None);

Also available: assert_status / assert_status_in / assert_status_4xx / assert_status_5xx, assert_header, assert_content_type, assert_redirect_chain, assert_cookie_not_set, and assert_messages.


See also