Testen
Schnelle, zuverlässige Tests müssen deine App so ansteuern, wie es ein Client tut — ohne
einen Server zu booten oder das Netzwerk zu berühren. Rustangos TestClient führt deinen
Router in-process aus: du rufst client.get("/path") auf, es routet die Anfrage
durch den echten Stack (Extractors, Middleware, Handler) und gibt dir die
Antwort zum Assertieren zurück. Füge Transaction-Rollback-Isolation für Datenbanktests und
ein Set von Response-Assertions hinzu, und du hast eine vollständige
Integrationstest-Umgebung, ohne die Prozessgrenze zu verlassen.
Neu bei einem Begriff hier? router, handler, fixture, rollback — siehe das Glossar.
Quelle:
rustango::test_client(TestClient,TestResponse),rustango::test_assertions(assert_status_2xx,assert_redirects,assert_cookie_set, …) undrustango::test_db(with_rollback) — immer kompiliert.Lauffähige Version: die untenstehenden Snippets sind ein bestehender Test —
testing_doc.rs(cargo test -p rustango --test testing_doc). Nahezu jede andere*_doc.rsin diesem Repo verwendetTestClientauf dieselbe Weise.
Inhaltsverzeichnis
- Schritt 1 — Steuere deine App mit TestClient an
- Schritt 2 — Assertiere auf die Response
- JSON, Header und Bodies senden
- Eine echte API testen
- Datenbanktests mit Rollback
- Live-Suites, und warum ein grüner Lauf nichts beweisen muss
- Response-Assertion-Helper
- Siehe auch
Schritt 1 — Steuere deine App mit TestClient an
Umschließe einen beliebigen axum::Router in einem TestClient und sende Anfragen — kein Socket wird gebunden,
kein Server-Task gestartet. Die Anfrage fließt durch deine echte Middleware und
Handler:
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 hat get / post / put / patch / delete / head, jeweils
einen Builder zurückgebend, den du mit .send().await abschließt.
Schritt 2 — Assertiere auf die Response
TestResponse macht den Status und Body in jeder Form verfügbar, die du brauchst:
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");
JSON, Header und Bodies senden
Der Request-Builder verkettet alles vor .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);
Nutze .body(...) für rohe (Nicht-JSON-)Bodies, und eine fehlende Route gibt ein echtes
404 zurück — verifiziert im zugrunde liegenden Test.
Eine echte API testen
app() in deinen Tests ist einfach dein Router. Für eine DB-gestützte API baue ihn genau
so, wie es main.rs tut, aber mit einem Test-Pool — das Muster, das die meisten *_doc.rs-Tests verwenden:
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());
}
Dies ist der ViewSets-Test aus jenem Leitfaden — derselbe TestClient.
Datenbanktests mit Rollback
Tests, die in eine Datenbank schreiben, dürfen keinen Zustand ineinander lecken lassen.
test_db::with_rollback führt deinen Test innerhalb einer Transaktion aus und rollt sie zurück
am Ende, sodass jeder Test vom selben sauberen Zustand startet und nichts persistiert wird:
use rustango::test_db::with_rollback;
#[tokio::test]
async fn creating_a_post_persists_it() {
with_rollback(&pool, |tx| async move {
// ... insert + assert against `tx` ...
// everything here is rolled back when the closure returns
}).await;
}
Für SQLite verwenden die *_sqlite_live.rs-Tests überall in diesem Repo stattdessen eine In-Memory-
Datenbank pro Test — ebenfalls vollständig isoliert, mit null externem Setup.
Live-Suites, und warum ein grüner Lauf nichts beweisen muss
Tests mit dem Namen *_live.rs sprechen mit einer echten Datenbank. Die meisten
brauchen nichts von dir; der Rest braucht eine Umgebungsvariable, und wenn sie
fehlt, schlagen sie nicht fehl. Sie machen ein return, und der Lauf meldet
Erfolg.
Das ist Absicht — es hält cargo test auf einem Laptop ohne Server lauffähig —
aber es bedeutet, dass ein bestandener Lauf kein Beleg dafür ist, dass die Suite
gelaufen ist. Gut zu wissen, bevor du ein grünes Ergebnis als Abdeckung liest.
Welche Variable jede Suite will
| Variable | Suites | Was sie brauchen |
|---|---|---|
| (keine) | 211 | Nichts — eine In-Memory- oder temporäre Datei-SQLite. Laufen immer. |
DATABASE_URL | 96 | Ein erreichbarer PostgreSQL-Server. |
MYSQL_TEST_URL | 28 | Ein erreichbarer MySQL-8+-Server. Nicht DATABASE_URL. |
REDIS_TEST_URL | 2 | Ein erreichbares Redis. |
Eine Suite, die zwei Variablen liest, wird unter beiden gezählt, die Spalte summiert sich also nicht auf die Anzahl der Dateien.
Die *_tri.rs-Suiten werden unter beiden Server-Variablen gezählt. Sie lesen
selbst keine Variable — Backend::pool() übernimmt das — und ihr SQLite-Arm
läuft auch ohne gesetzte Variablen. Sie als „braucht nichts“ zu zählen wäre
deshalb formal haltbar und praktisch falsch: Die beiden Arme, die einen Server
brauchen, sind der Grund für diese Suiten. Starte beide Server, sonst meldet
eine Tri-Suite eine gesunde Trefferzahl und hat ein Backend von dreien geprüft.
MySQL ist das, worüber Leute stolpern: es liest seine eigene Variable, also führt
eine Shell, in der nur DATABASE_URL gesetzt ist, die Postgres-Suites aus und
überspringt stillschweigend jede MySQL-Suite.
Einen Skip von einem Pass unterscheiden
Die meisten Skips sind ein nacktes frühes return ganz ohne Ausgabe. Eine
Minderheit gibt zuvor eine Zeile auf stderr aus, die cargo test versteckt,
solange du nicht danach fragst:
cargo test --test <name> -- --nocapture
Das verlässliche Signal ist die Anzahl. Eine Live-Suite, die 0 passed meldet —
oder weit weniger, als die Datei enthält —, hat übersprungen.
running 2 tests … 2 passed ohne laufenden Server heißt, dass diese beiden Tests
früh zurückgekehrt sind.
Wenn du willst, dass eine Suite fehlschlägt statt zu überspringen, wenn ihr Server fehlt, setze die Variable auf eine absichtlich falsche URL: sie schlägt dann beim Verbinden fehl, was ein lauteres und ehrlicheres Signal ist als ein Skip.
Response-Assertion-Helper
Für rohe axum::Response-Werte (z. B. aus tower::oneshot) liefert test_assertions
lesbare Ein-Zeilen-Prüfungen für Status, Inhalt, Redirects und 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);
Ebenfalls verfügbar: assert_status / assert_status_in / assert_status_4xx /
assert_status_5xx, assert_header, assert_content_type,
assert_redirect_chain, assert_cookie_not_set und assert_messages.
Siehe auch
- ViewSets · HTML-Views — worauf du den
TestClientrichtest. - Middleware —
TestClientübt auch die Layer aus (DB-frei, viatower::oneshotinmiddleware.rs). - Erste Schritte — Schritt 16 schreibt den ersten Test.
manageCLI —make:testscaffoldet ein Test-Modul.
