Manual contentsTestingBrowse 113 chapters
Manual 25 min read

HTTP Tests

This chapter shows how to test your HTTP surface - routes, middleware, auth flows, error responses - by driving the framework's request pipeline through suprnova::handle_request. If you've written Laravel feature tests with $this->get('/users') and asserted on $response->status(), this is the Suprnova equivalent: the same Router you mount in production runs in the test, every middleware fires, the panic boundary still catches, and the response is byte-for-byte what a real client sees.

The test surface

There are exactly three building blocks:

Piece Role
Router The routes under test - built the same way as in production
MiddlewareRegistry The global middleware stack - also built the same way
handle_request(router, registry, req) -> hyper::Response<…> The in-process driver - runs one request end-to-end

handle_request is the same function Server::run calls per request, exposed for tests and embedders. Anything that works in production works here - the panic-recovery wrapper, the request-id scope, the Inertia flash-bag scope, the auth request state scope, the HEAD-body strip, post-response termination. There is no "test mode" that swaps a quieter pipeline in.

handle_request_with_peer is the same call with an explicit Option<std::net::IpAddr> for the connecting peer - useful when you want to assert on Request::ip() resolution without setting up proxy headers.

The hyper body problem

The one wrinkle worth knowing about up front: handle_request takes a hyper::Request<hyper::body::Incoming>. Incoming is hyper's internal streaming body type; you cannot construct one with Full::new(bytes) or any of the in-memory body types. It only comes out of a hyper connection.

There are two clean ways around it:

  1. TCP loopback - bind a 127.0.0.1:0 listener, serve one accept inside a service_fn, send the request through a hyper client, and let Incoming be produced naturally on the server side. This is what every integration test in the framework already does.
  2. In-process Request building - for tests that only need to inspect Request accessors (headers, route params, IP, JSON parsing) without going through routing, use the same TCP-loopback capture pattern but with a service that pulls the Request out into a oneshot::channel instead of running it. The framework/tests/http/request_accessors.rs file has this build_request() helper verbatim.

Both patterns produce real Incoming bodies. The loopback is local, synchronous in test wall-clock terms (microseconds), and never touches the network outside lo. There is no slower or simpler way that preserves the contract.

Why Suprnova diverges

Laravel's $this->get('/users') works because PHP's request lifecycle is "build a Request object, dispatch it through the kernel". The kernel takes the in-memory object directly; there is no body type that forces a transport. Suprnova's server is built on hyper, and hyper's body type is opinionated for good reasons (streaming, backpressure, zero-copy). The test surface inherits that constraint.

What you trade for the constraint is fidelity. Every detail of the production request path - header parsing, body limits, connection upgrades - runs the same way in tests. You will never have a test pass because the test harness skipped a layer the real server runs.

A first end-to-end test

Here is a complete, working test that mounts a single route, sends a GET against it, and asserts on the status and body.

use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;

use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;

use suprnova::http::text;
use suprnova::{MiddlewareRegistry, Request, Router, handle_request};

async fn spawn_server(
    router: Router,
    middleware: MiddlewareRegistry,
    accepts: usize,
) -> SocketAddr {
    let router = Arc::new(router);
    let middleware = Arc::new(middleware);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind ephemeral listener");
    let addr = listener.local_addr().expect("local_addr");

    tokio::spawn(async move {
        for _ in 0..accepts {
            let Ok((stream, _)) = listener.accept().await else { return };
            let io = TokioIo::new(stream);
            let router = router.clone();
            let middleware = middleware.clone();
            tokio::spawn(async move {
                let svc = service_fn(move |req: hyper::Request<Incoming>| {
                    let router = router.clone();
                    let middleware = middleware.clone();
                    async move {
                        Ok::<_, Infallible>(handle_request(router, middleware, req).await)
                    }
                });
                let _ = hyper::server::conn::http1::Builder::new()
                    .serve_connection(io, svc)
                    .await;
            });
        }
    });

    addr
}

async fn send_get(addr: SocketAddr, path: &str) -> (u16, Bytes) {
    let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
    let io = TokioIo::new(stream);
    let (mut sender, conn) =
        hyper::client::conn::http1::handshake::<_, Full<Bytes>>(io).await.unwrap();
    tokio::spawn(async move { let _ = conn.await; });

    let req = hyper::Request::builder()
        .method("GET")
        .uri(path)
        .header("Host", "localhost")
        .header("Content-Length", "0")
        .body(Full::new(Bytes::new()))
        .unwrap();

    let resp = tokio::time::timeout(Duration::from_secs(5), sender.send_request(req))
        .await
        .expect("send_get timeout")
        .expect("hyper send_request");
    let (parts, body) = resp.into_parts();
    let bytes = body.collect().await.unwrap().to_bytes();
    (parts.status.as_u16(), bytes)
}

#[tokio::test]
async fn get_root_returns_hello() {
    let router = Router::new().get("/", |_req: Request| async { text("hello") });
    let addr = spawn_server(router.into(), MiddlewareRegistry::new(), 1).await;

    let (status, body) = send_get(addr, "/").await;
    assert_eq!(status, 200);
    assert_eq!(&body[..], b"hello");
}

That's the entire shape. Copy the two helpers per crate, tune them for the suite (multiple accepts, header capture, body capture). The framework itself uses near-identical helpers in framework/tests/cors/middleware.rs, framework/tests/middleware/panic_safety.rs, and framework/tests/auth_flows/email_verified_middleware.rs.

The accepts argument bounds how many connections the accept loop serves before exiting. One is enough for a single request; bump to two-or-more when a test exercises post-panic recovery (see Testing the panic boundary).

Building a request

Inside send_get you saw:

let req = hyper::Request::builder()
    .method("GET")
    .uri("/users/42")
    .header("Host", "localhost")
    .header("Content-Length", "0")
    .body(Full::new(Bytes::new()))
    .unwrap();

That's the canonical shape. A few things worth knowing:

  • Host header. Hyper rejects HTTP/1.1 requests without one. Always include it; the value doesn't matter unless your handler keys on it.
  • Content-Length: 0. Match the body. Hyper computes this for you with Full::new(Bytes::new()), but being explicit reads cleaner in tests.
  • Body types. The client side sends Full<Bytes>. The server side receives Incoming. You only ever build Full<Bytes> requests in tests; the framework receives them as Incoming after hyper's per-connection conversion.

A POST with a JSON body:

let body_bytes = serde_json::to_vec(&serde_json::json!({
    "name": "Alice",
    "email": "alice@example.com"
})).unwrap();

let req = hyper::Request::builder()
    .method("POST")
    .uri("/users")
    .header("Host", "localhost")
    .header("content-type", "application/json")
    .header("content-length", body_bytes.len())
    .body(Full::new(Bytes::from(body_bytes)))
    .unwrap();

Asserting on the response

The response that comes back from handle_request is a hyper::Response<BoxBody<Bytes, Infallible>>. Three things you'll read off it:

let (parts, body) = resp.into_parts();

// 1. Status.
assert_eq!(parts.status.as_u16(), 200);

// 2. Headers - case-insensitive lookup.
let location = parts.headers.get("location").and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/login"));

// 3. Body - collect into bytes, then parse.
use http_body_util::BodyExt;
let bytes = body.collect().await.unwrap().to_bytes();

// As text:
let text = String::from_utf8_lossy(&bytes);

// As JSON:
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["message"], "ok");

For ordinary error responses that reach the common renderer, the body shape documented in Error Model includes message, optional errors, request_id, and an optional debug_message. request_id is null outside a request scope. Three special variants return before request-id injection: PrecognitionSuccess is a bodyless 204, PrecognitionFailure is the validation body plus Precognition headers, and an accidentally HTTP-rendered AlreadyReported sentinel is a generic 500 containing only message. Use an ordinary error response when asserting that request-id middleware ran.

Fluent response assertions with TestResponse

Building the (status, headers, body) triple by hand and asserting on it piece by piece, as above, is the foundation every harness in this crate uses. suprnova::testing::TestResponse wraps that same triple in a fluent, Laravel-shaped API, so a test reads like an assertion instead of a header lookup:

use suprnova::testing::TestResponse;

let (parts, body) = resp.into_parts();
let bytes = body.collect().await.unwrap().to_bytes();
let headers = parts.headers.iter().map(|(k, v)| {
    (k.as_str().to_string(), v.to_str().unwrap_or_default().to_string())
});

TestResponse::new(parts.status.as_u16(), headers, bytes)
    .assert_ok()
    .assert_header("content-type", "application/json")
    .assert_json(serde_json::json!({ "message": "ok" }));

new() accepts anything iterable as (String, String) header pairs - a HashMap<String, String> (what several existing harnesses already collect into), a Vec<(String, String)>, or HeaderMap::iter() mapped to owned strings - so no harness has to change how it drives a request.

Every assertion returns &Self, so they chain: assert_status, assert_ok, assert_redirect(target: Option<&str>), assert_json (subset match - extra keys in the body are fine), assert_json_path (dot notation, a numeric segment indexes an array), assert_json_count, assert_see, assert_header, assert_cookie. Assertion failures panic with an expected/actual excerpt, the same contract as expect! (Testing) - this is a testing surface, not library code, so the no-panic house rule doesn't apply.

assert_session_has needs a session store

Every other assertion reads only the wire-level response. assert_session_has can't: server-side session state lives in the SessionStore, not in the response, and by the time a response comes back over the loopback socket there is no in-process session left to read. Attach the same store the test's SessionMiddleware was built with, plus its cookie name, and the assertion decrypts the response's session cookie to find the row itself:

let response = TestResponse::new(status, headers, body)
    .with_session_store(middleware.store(), "suprnova_session");

response
    .assert_session_has("flash.success", serde_json::json!("Saved!"))
    .await;

It's the only async assertion, since it's the only one that does I/O; it still returns &Self, so .await sits inline and the chain continues after it.

Why Suprnova diverges

Laravel's TestResponse lives in the same PHP process as the app under test, so assertSessionHas reads $this->session() directly - no wire boundary to cross. Suprnova's tests drive a real hyper connection, so the session is exactly as opaque to the test as it is to a real browser: a cookie. assert_session_has earns that honesty back with an explicit store handle instead of pretending the in-process shortcut exists.

Testing Inertia responses

suprnova::testing::AssertableInertia wraps an Inertia page object - whether it came back as an X-Inertia JSON body or embedded in a hard-navigation HTML shell - in the same fluent, panic-on-failure style as TestResponse. Laravel's Inertia\Testing\AssertableInertia equivalent.

Two ways to get one. From a TestResponse that already went through a real X-Inertia: true visit:

use suprnova::testing::TestResponse;

let response = TestResponse::new(status, headers, body);
response
    .assert_inertia()
    .component("Users/Index")
    .url("/users")
    .has("users")
    .where_("users.0.name", "Ada")
    .count("users", 1)
    .missing("admin_only_field");

Or directly from an HttpResponse - what InertiaResponse::resolve returns - for a test that drives the response pipeline without a socket. This form handles both shapes: an X-Inertia JSON body, or the HTML shell's embedded <script data-page="app"> element:

use suprnova::testing::AssertableInertia;

let response = InertiaResponse::new("Users/Index")
    .with("users", users_json)
    .resolve(&req)
    .await?;

AssertableInertia::from_response(&response)
    .component("Users/Index")
    .where_("users.0.name", "Ada");

version() checks the page's asset version. The default resolver hashes the Vite manifest and falls back to MANIFEST_VERSION_FALLBACK when no manifest exists yet - assert against that constant rather than a hardcoded "1.0" in a test that hasn't built a frontend:

use suprnova::MANIFEST_VERSION_FALLBACK;

response.assert_inertia().version(MANIFEST_VERSION_FALLBACK);

has_flash(key, expected) reads the page's flash data the same dot-path way has / where_ reads props - expected is an Option, so pass None::<serde_json::Value> to check presence only:

response.assert_inertia().has_flash("toast.message", Some(serde_json::json!("Saved!")));
response.assert_inertia().has_flash("toast", None::<serde_json::Value>);

Reloading for partial-reload and deferred-props assertions

reload_only, reload_except, and load_deferred_props mirror what the Inertia client does after the initial visit: reissue the same page as a partial reload and check what came back. Because Suprnova's HTTP tests cross a real socket and every test file owns its own harness (see Where each piece lives below), these methods carry no built-in transport - attach one with with_reload, a closure from a ReloadRequest (the url, component, version, and partial-reload keys to send) to a future producing the reloaded AssertableInertia:

use suprnova::testing::TestResponse;

let assertable = TestResponse::new(status, headers, body)
    .assert_inertia()
    .with_reload(move |reload| {
        async move {
            let header_pairs = reload.headers();
            let headers: Vec<(&str, &str)> = header_pairs
                .iter()
                .map(|(k, v)| (k.as_str(), v.as_str()))
                .collect();
            let (status, headers, body) = request(addr, "GET", &reload.url, &headers).await;
            TestResponse::new(status, headers, body).assert_inertia()
        }
    });

// Requests only `users`, and asserts the reload landed on the same
// component/url/version and that `users` came back.
assertable.reload_only(["users"]).await;

// Requests everything except `stats`, and asserts `stats` is absent.
assertable.reload_except(["stats"]).await;

// Reads `deferredProps` off the original page, requests every deferred
// key in one partial reload, and asserts they all came back.
assertable.load_deferred_props().await;

Calling any of the three without with_reload first panics with that instruction. A reload's result carries the same reloader forward, so a second .reload_only(...).await off it works without reattaching one.

Why Suprnova diverges

Laravel's ReloadRequest reissues the request through the same in-process PHP kernel the original test used - one test client, always available. Suprnova's HTTP tests drive a real hyper/TCP loopback and each test file defines its own spawn_server / request pair (see Where each piece lives below), so there is no single client AssertableInertia could reach for - with_reload makes that explicit instead of hardcoding a harness a differently shaped test file couldn't use. component() also skips Laravel's page-component file-existence check (view-finder) - a component reached through Router::inertia or a hand-rolled InertiaResponse::new(name) is a runtime string with no file to check; Suprnova's compile-time equivalent is the inertia_response! macro (see Inertia Responses). Its method names also diverge from TestResponse's: component, has, missing, where_, count, and has_flash drop the assert_ prefix entirely, matching Laravel's Inertia\Testing\AssertableInertia, whose equivalent methods are bare the same way - the panic-on-failure contract is identical either way, without the assert_ visual cue.

Testing middleware

Middleware tests look identical to route tests; the only difference is what you .append() to the registry before spawning.

Testing global middleware

Pass the middleware to MiddlewareRegistry::new().append(...) and use that registry - multiple middlewares run in append order, prepend puts a new one at the front.

use suprnova::{CorsConfig, CorsMiddleware, MiddlewareRegistry};

fn cors_registry() -> MiddlewareRegistry {
    MiddlewareRegistry::new().append(CorsMiddleware::new(
        CorsConfig::allow_origins(["https://app.example"])
            .allow_credentials(true)
            .max_age(std::time::Duration::from_secs(600)),
    ))
}

#[tokio::test]
async fn cors_preflight_returns_204_with_headers() {
    let router = Router::new();
    let addr = spawn_server(router, cors_registry(), 1).await;

    let (status, headers, _) = options(
        addr,
        "/anything",
        &[
            ("Origin", "https://app.example"),
            ("Access-Control-Request-Method", "POST"),
        ],
    ).await;

    assert_eq!(status, 204);
    assert_eq!(
        headers.get("access-control-allow-origin").map(String::as_str),
        Some("https://app.example"),
    );
}

This test proves more than the CORS logic itself: it proves that global middleware runs on unrouted requests too, which is the contract the framework guarantees (otherwise an OPTIONS preflight that never matches a route would skip CORS). See framework/tests/cors/middleware.rs for the full suite.

Testing route-specific middleware

Attach with .middleware(...) on the route builder, exactly like production. Then test the route as normal - the middleware chain is built off the same registration.

let router = Router::new()
    .get("/admin/dashboard", |_req| async { text("admin") })
    .middleware(RequireRole::new("admin"));

let (status, _) = send_get(addr, "/admin/dashboard").await;
assert_eq!(status, 403); // unauthenticated request

Stubbing the authenticated user

Real auth-flow tests need a logged-in user. The cleanest pattern is a tiny one-off middleware that calls Auth::set_user ahead of the middleware under test. The framework's own framework/tests/auth_flows/email_verified_middleware.rs uses this:

use std::any::Any;
use std::sync::Arc;
use suprnova::{Auth, Authenticatable, Middleware, Next, Request, Response};

struct UserById(String);

impl Authenticatable for UserById {
    fn get_auth_identifier(&self) -> String { self.0.clone() }
    fn as_any(&self) -> &dyn Any { self }
}

struct LoginAs(String);

#[async_trait::async_trait]
impl Middleware for LoginAs {
    async fn handle(&self, request: Request, next: Next) -> Response {
        Auth::set_user(Arc::new(UserById(self.0.clone())));
        next(request).await
    }
}

Then in the test:

let registry = MiddlewareRegistry::new()
    .append(LoginAs("user-id-123".to_string()))
    .append(EnsureEmailVerifiedMiddleware::new());

LoginAs runs first, installs the user into the per-request auth state, and the middleware under test sees Auth::id() == Some(...) without ever issuing a real login. The auth state scope is set up by handle_request itself - the same one that runs in production - so the user is visible to every later middleware and the handler.

Testing route model binding

RouteParam<User> hydrates a typed User through the handler's extractor chain, so the test must pass that extractor to a #[handler] function:

use suprnova::{RouteParam, Response, handler};

#[suprnova::model(table = "users")]
pub struct User {
    pub id: i64,
    pub email: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[handler]
async fn show(RouteParam(user): RouteParam<User>) -> Response {
    suprnova::http::json(serde_json::json!({ "email": user.email }))
}

#[tokio::test]
async fn show_user_binds_from_route_param() {
    // Insert a test user via the model. Database setup omitted -
    // see the testing chapter for `TestDatabase` patterns.
    let user = User::create(suprnova::attrs! {
        email: "bound@example.com"
    }).await.unwrap();

    // A destructured RouteParam currently uses `param` as the handler
    // macro's route-parameter name.
    let router: Router = Router::new()
        .get("/users/{param}", show)
        .into();

    let addr = spawn_server(router, MiddlewareRegistry::new(), 1).await;
    let (status, body) = send_get(addr, &format!("/users/{}", user.id)).await;

    assert_eq!(status, 200);
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["email"], "bound@example.com");
}

For a {user} route parameter instead, accept user: RouteParam<User> without destructuring; RouteParam dereferences to User for field access. Calling req.param(...).parse() and then User::find_or_fail(...) tests parameter parsing and model lookup, not route-model binding.

For binding-in-isolation tests, call <RouteParam<User> as AutoRouteBinding>::from_route_param(...) directly. That checks the binding implementation without a router, but it does not exercise the #[handler] extractor chain.

Testing auth flows end-to-end

To test a login session end to end, pass a registry containing SessionMiddleware to the loopback server and protect /dashboard with AuthMiddleware or the application's web-auth middleware. First prove the route rejects a cookieless request, then log in, replay the returned session cookie, and prove the protected route succeeds:

#[tokio::test]
async fn login_flow_issues_session_cookie() {
    // 1. Bootstrap: create the user.
    Auth::password()
        .register("alice@example.com", "longpassword123")
        .await.expect("register");

    // 2. Mount a protected route and the stateful session middleware.
    let router: Router = Router::new()
        .post("/login", login_handler)
        .get("/dashboard", |_req: Request| async { text("dashboard") })
        .middleware(AuthMiddleware::new())
        .into();
    let registry = MiddlewareRegistry::new()
        .append(SessionMiddleware::new(SessionConfig::from_env()));
    let addr = spawn_server(router, registry, 3).await;

    // 3. Prove the route is protected before authenticating.
    let (guest_status, _) = send_get(addr, "/dashboard").await;
    assert_eq!(guest_status, 401);

    // 4. Drive login and capture the Set-Cookie header.
    let login = post_json(addr, "/login", serde_json::json!({
        "email": "alice@example.com",
        "password": "longpassword123",
    })).await;
    assert_eq!(login.status, 200);
    let cookie = extract_session_cookie(&login.headers);

    // 5. Replay the cookie against the protected route.
    let (status, body) = get_with_cookie(addr, "/dashboard", &cookie).await;
    assert_eq!(status, 200);
    assert_eq!(&body[..], b"dashboard");
}

The abbreviated router without those middlewares demonstrates cookie plumbing only; it is not an authentication-flow test. framework/tests/auth/http_middleware.rs tests authentication middleware behavior with explicit registries, but it does not install a real SessionMiddleware. A stateful login-flow test must install both the session middleware and the authentication gate as shown above.

Testing the panic boundary

A panic inside a handler must not crash the server. The panic-recovery wrapper (execute_chain_safely) catches it and converts to a 500 through the same path returned errors flow through. You can verify this without any special test infrastructure - set accepts >= 2 so the listener survives the panic:

#[tokio::test]
async fn panicking_handler_yields_500_and_server_survives() {
    let router = Router::new()
        .get("/panic", |_req: Request| async {
            panic!("intentional test panic");
            #[allow(unreachable_code)] text("unreachable")
        })
        .get("/ok", |_req: Request| async { text("ok") });

    let addr = spawn_server(router.into(), MiddlewareRegistry::new(), 4).await;

    // First: the panic translates to a sanitised 500.
    let (s1, body) = send_get(addr, "/panic").await;
    assert_eq!(s1, 500);
    let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(parsed["message"], "Internal Server Error");
    assert!(parsed.get("request_id").is_some());

    // Second: the listener survives. The next request is normal.
    let (s2, body2) = send_get(addr, "/ok").await;
    assert_eq!(s2, 200);
    assert_eq!(&body2[..], b"ok");
}

Testing accessors without going through routing

Sometimes you want to test a Request accessor (bearer_token, is_method, ip, is_json, etc.) without spinning up a router at all. The trick is a tiny harness that runs a hyper service whose only job is to construct the Request and ship it back through a tokio::sync::oneshot::channel:

let (req_tx, req_rx) = tokio::sync::oneshot::channel::<suprnova::Request>();
// ... loopback hyper service whose service_fn does:
//     let req = suprnova::Request::new(hyper_req);
//     let _  = req_tx.send(req);
//     return a 200 with an empty body
let req = req_rx.await.unwrap();

framework/tests/http/request_accessors.rs has the full build_request(builder, body) -> Request helper. Copy it once per crate and every accessor test reads cleanly:

#[tokio::test]
async fn bearer_token_extracts_simple_token() {
    let req = build_request(
        hyper::Request::builder()
            .method("GET")
            .uri("/api/users")
            .header("Authorization", "Bearer secret-token-123"),
        "",
    ).await;
    assert_eq!(req.bearer_token().as_deref(), Some("secret-token-123"));
}

The Request is real (produced by hyper from a real wire exchange), but no routing or middleware ran - exactly what you want when the unit under test is the accessor itself.

Builder hooks on Request

When you have a Request in hand and need to fake one piece of the routing layer, three builder methods help:

impl Request {
    pub fn with_params(mut self, params: HashMap<String, String>) -> Self;
    pub fn with_route_pattern(mut self, pattern: String) -> Self;
    pub fn with_peer_addr(mut self, addr: std::net::IpAddr) -> Self;
}

These are the same methods the server calls when it dispatches a matched route - Router calls with_params after matchit returns, with_route_pattern so req.route_pattern() resolves, and with_peer_addr once it knows the accepted-TCP socket's IP. In tests you call them yourself to short-circuit the same setup.

let req = Request::new(hyper_req)
    .with_params(HashMap::from([("id".into(), "42".into())]))
    .with_route_pattern("/users/{id}".into())
    .with_peer_addr("192.168.1.10".parse().unwrap());

assert_eq!(req.param("id").unwrap(), "42");
assert_eq!(req.ip(), Some("192.168.1.10".parse().unwrap()));

Things to know

A short list of footguns that catch first-time authors:

  • Incoming is server-side only. You cannot build one in your test. The TCP loopback (or in-process service capture) is the only path - there is no "build a Request from a Vec<u8> body" constructor.
  • Don't share state between tests. Each #[tokio::test] gets its own runtime; cross-test pollution usually means you're sharing a global (once_cell, lazy_static, env var). For DB state see TestDatabase in Testing.
  • Cookies need a real client. No automatic cookie jar - thread Set-Cookie from one response into Cookie on the next. See framework/tests/auth/http_middleware.rs for the pattern.
  • The post-response termination spawn is non-blocking. If you want to assert on side effects that run via Terminable, poll for them - the response returns to the client before the hook runs.

Where each piece lives

Piece File
handle_request, handle_request_with_peer framework/src/server.rs
Request::new, with_params, with_route_pattern, with_peer_addr framework/src/http/request.rs
MiddlewareRegistry::new, append, prepend framework/src/middleware/registry.rs
Loopback test harness (canonical) framework/tests/cors/middleware.rs
TestResponse (fluent assertions over the triple above) framework/src/testing/response.rs
AssertableInertia, ReloadRequest (fluent Inertia page-object assertions) framework/src/testing/inertia.rs
In-process Request capture harness framework/tests/http/request_accessors.rs
Panic-boundary test pattern framework/tests/middleware/panic_safety.rs
Auth + middleware end-to-end pattern framework/tests/auth_flows/email_verified_middleware.rs

Next

  • Testing - #[suprnova_test], TestDatabase, the describe!/test!/expect! macros, and the unit-level surface
  • Error Model - the JSON shape every error response uses, the 5xx sanitisation rule, and what request_id means in a test body
  • Middleware - writing the middleware you test here, and the global-vs-route lifecycle
  • Routing - the Router you mount in both production and tests, route params, route names, signed URLs
  • Authentication - the Auth facade, Authenticatable, guards, and how Auth::set_user interacts with the request scope handle_request installs