Test the bytes, not the function
Here's a test that looks fine and is nearly worthless:
#[test]
fn home_page_has_a_title() {
let meta = PageMeta::new("Home", "...", "/");
assert_eq!(meta.title_tag(), "Home — Aneesh Saripalli");
}
It verifies that a struct method returns a string. It says nothing about whether
a browser requesting / receives a <title> element. Those are different
claims, and the gap between them is where bugs live: the handler might not use
PageMeta at all, the layout might drop the title, a middleware might mangle
the response.
For anything SEO-shaped this distinction isn't academic. The artifact that matters is the bytes on the wire. Google doesn't call your functions.
Testing the actual response
Axum makes this genuinely easy, but only if you structure for it. The move is to separate router construction from process startup:
// app.rs — builds the routing tree, binds nothing
pub fn app() -> Router {
Router::new().route("/", get(pages::home))
}
// main.rs — owns the socket
axum::serve(listener, app()).await
Now a test can drive the real router through tower::ServiceExt::oneshot:
async fn get(path: &str) -> (StatusCode, String) {
let response = app()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(bytes.to_vec()).unwrap())
}
No port binding. No "sleep 500ms and hope the server is up". No cleanup, no port collisions when tests run in parallel. Real routing, real middleware, real status codes, real response body — the whole stack except the socket.
The full suite runs in single-digit milliseconds.
What this unlocks
Once you're asserting on response bytes, you can write tests that would otherwise be impossible to express:
#[tokio::test]
async fn canonical_url_is_absolute_and_matches_the_request_path() {
for path in INDEXABLE_PATHS {
let (_, body) = get(path).await;
let expected =
format!(r#"<link rel="canonical" href="{SITE_ORIGIN}{path}">"#);
assert!(body.contains(&expected));
}
}
That's checking a property of the document, across every route, in a way that survives refactoring the internals completely. Rewrite the templating layer and this test still means exactly what it meant before.
A few others that earn their keep:
- Hard 404s. A missing page served with a 200 status ("soft 404") gets indexed as real content. Assert the status code, not just the body.
- Compression negotiation. Send
Accept-Encoding: gzip, assertContent-Encoding: gzipcomes back. Page weight feeds Core Web Vitals. - Sitemap liveness. Parse the sitemap, request every URL it advertises, assert 200. This one has caught real drift for me already.
The general principle
Test at the boundary that your actual consumer observes.
For a library, that's the public API. For an HTTP service whose consumers include search crawlers, link unfurlers, and RSS readers, it's the response bytes. Testing one layer beneath that boundary feels rigorous and quietly isn't — it verifies that your components work, not that your system does.
The useful question isn't "does this function return the right value". It's "if I break this, does anything go red before a user notices?"