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:
- TCP loopback - bind a
127.0.0.1:0listener, serve one accept inside aservice_fn, send the request through a hyper client, and letIncomingbe produced naturally on the server side. This is what every integration test in the framework already does. - In-process Request building - for tests that only need to
inspect
Requestaccessors (headers, route params, IP, JSON parsing) without going through routing, use the same TCP-loopback capture pattern but with a service that pulls theRequestout into aoneshot::channelinstead of running it. Theframework/tests/http/request_accessors.rsfile has thisbuild_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 Infallible;
use SocketAddr;
use Arc;
use Duration;
use Bytes;
use ;
use Incoming;
use service_fn;
use TokioIo;
use text;
use ;
async
async
async
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 = builder
.method
.uri
.header
.header
.body
.unwrap;
That's the canonical shape. A few things worth knowing:
Hostheader. 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 withFull::new(Bytes::new()), but being explicit reads cleaner in tests.- Body types. The client side sends
Full<Bytes>. The server side receivesIncoming. You only ever buildFull<Bytes>requests in tests; the framework receives them asIncomingafter hyper's per-connection conversion.
A POST with a JSON body:
let body_bytes = to_vec.unwrap;
let req = builder
.method
.uri
.header
.header
.header
.body
.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 = resp.into_parts;
// 1. Status.
assert_eq!;
// 2. Headers - case-insensitive lookup.
let location = parts.headers.get.and_then;
assert_eq!;
// 3. Body - collect into bytes, then parse.
use BodyExt;
let bytes = body.collect.await.unwrap.to_bytes;
// As text:
let text = Stringfrom_utf8_lossy;
// As JSON:
let value: Value = from_slice.unwrap;
assert_eq!;
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 TestResponse;
let = resp.into_parts;
let bytes = body.collect.await.unwrap.to_bytes;
let headers = parts.headers.iter.map;
new
.assert_ok
.assert_header
.assert_json;
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 = new
.with_session_store;
response
.assert_session_has
.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 TestResponse;
let response = new;
response
.assert_inertia
.component
.url
.has
.where_
.count
.missing;
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 AssertableInertia;
let response = new
.with
.resolve
.await?;
from_response
.component
.where_;
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 MANIFEST_VERSION_FALLBACK;
response.assert_inertia.version;
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;
response.assert_inertia.has_flash;
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 TestResponse;
let assertable = new
.assert_inertia
.with_reload;
// Requests only `users`, and asserts the reload landed on the same
// component/url/version and that `users` came back.
assertable.reload_only.await;
// Requests everything except `stats`, and asserts `stats` is absent.
assertable.reload_except.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 ;
async
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 = new
.get
.middleware;
let = send_get.await;
assert_eq!; // 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 Any;
use Arc;
use ;
;
;
Then in the test:
let registry = new
.append
.append;
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 ;
async
async
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:
async
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:
async
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 = ;
// ... 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:
async
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:
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 = new
.with_params
.with_route_pattern
.with_peer_addr;
assert_eq!;
assert_eq!;
Things to know
A short list of footguns that catch first-time authors:
Incomingis 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 aRequestfrom aVec<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 seeTestDatabasein Testing. - Cookies need a real client. No automatic cookie jar - thread
Set-Cookiefrom one response intoCookieon the next. Seeframework/tests/auth/http_middleware.rsfor 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, thedescribe!/test!/expect!macros, and the unit-level surface - Error Model - the JSON shape every error response
uses, the 5xx sanitisation rule, and what
request_idmeans in a test body - Middleware - writing the middleware you test here, and the global-vs-route lifecycle
- Routing - the
Routeryou mount in both production and tests, route params, route names, signed URLs - Authentication - the
Authfacade,Authenticatable, guards, and howAuth::set_userinteracts with the request scopehandle_requestinstalls
