Rustango docs
← Anleitungen

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.

Testen in Rustango: TestClient umschließt deinen Router und sendet In-process-Anfragen durch den echten Handler-Stack; die TestResponse macht Status, Text und JSON zum Assertieren verfügbar — kein Socket, kein Server

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, …) und rustango::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.rs in diesem Repo verwendet TestClient auf dieselbe Weise.

Inhaltsverzeichnis


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

VariableSuitesWas sie brauchen
(keine)211Nichts — eine In-Memory- oder temporäre Datei-SQLite. Laufen immer.
DATABASE_URL96Ein erreichbarer PostgreSQL-Server.
MYSQL_TEST_URL28Ein erreichbarer MySQL-8+-Server. Nicht DATABASE_URL.
REDIS_TEST_URL2Ein 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 TestClient richtest.
  • Middleware — TestClient übt auch die Layer aus (DB-frei, via tower::oneshot in middleware.rs).
  • Erste Schritte — Schritt 16 schreibt den ersten Test.
  • manage CLI — make:test scaffoldet ein Test-Modul.