Step 6 of 7
Testing handlers
Here's the payoff of handlers being plain functions: testing them is just calling them. No server, no network, no ports. Build a request, call the handler, check the status and the body. Teams write dozens of these per endpoint, and they run in milliseconds.
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
fn post(path: &str, body: &str) -> Response {
handle(&Request { method: Method::Post, path: path.to_string(), body: body.to_string() })
}
fn body_json(r: &Response) -> Value {
serde_json::from_str(&r.body).expect("response body is JSON")
}
#[test]
fn creating_a_user_returns_201_and_the_user() {
let r = post("/users", r#"{"name": "Ana", "email": "[email protected]"}"#);
assert_eq!(r.status, 201);
assert_eq!(body_json(&r), json!({ "id": 1, "name": "Ana", "email": "[email protected]" }));
}
#[test]
fn invalid_email_is_422() {
let r = post("/users", r#"{"name": "Bo", "email": "bo"}"#);
assert_eq!(r.status, 422);
assert_eq!(body_json(&r)["error"], "email must contain @");
}
}
What good handler tests do:
- Compare JSON as values, not strings. Parsing the body into a
serde_json::Valueand comparing withjson!ignores key order and spacing, so a harmless serializer change doesn't break every test.value["error"] == "..."checks one field. - Use small helpers (
post,get,body_json) so each test reads like a sentence about one behavior. - Test the unhappy paths at least as much as the happy one: bad JSON, missing fields, wrong method, unknown ids, and boundaries (exactly at a limit, one below it). Most production bugs live at the edges.
- Name tests after behavior:
half_needs_2000_cents, nottest3. When it fails in CI, the name is the first clue. - Table-driven tests cover many routes compactly:
#[test]
fn routes_answer_with_the_right_status() {
let cases = [
(Method::Get, "/items", 200),
(Method::Delete, "/items", 405),
(Method::Get, "/nope", 404),
];
for (method, path, want) in cases {
let r = handle(&Request { method, path: path.to_string(), body: String::new() });
assert_eq!(r.status, want, "{method:?} {path}");
}
}
The message argument ("{method:?} {path}") tells you which case failed.
With axum (next step), the same tests send a request through the whole router with tower::ServiceExt::oneshot, still without opening a port.
Your task
This time you write the tests. The handler below quotes a price for a shopping cart. Its rules: only POST /quote exists (another method on /quote is 405, other paths 404); the body is {"cents": <cart total>, "code": <optional discount code>}, and one that doesn't parse is 400; no code means the full price; CRAB10 takes 10% off; HALF halves the price but only for carts of at least 2000 cents (below that: 422); any other code is 422. Success is 200 with {"total": <cents>}. Write at least 4 tests that pin down these rules. Hidden broken versions of the handler (a wrong discount, an off-by-one boundary, wrong status codes) must each make at least one of your tests fail.
Challenge 2
Write tests for this router (at least 3). It maps a method and path to a status code: GET /orders 200, POST /orders 201, GET /orders/{id} 200 for a numeric id and 400 otherwise, DELETE /orders/{id} 204 (400 for a bad id); other methods on those paths 405; everything else 404; a trailing slash is allowed. A table-driven test (an array of (method, path, expected) and a loop) is the quickest way to cover it. Hidden broken routers must each fail one of your tests.
Challenge 3
Tests read better with a test client. Write TestClient, whose get(path) and post(path, body) call the given handle and return (status, Value), the status and the body parsed as JSON (an empty body becomes Value::Null). post takes the body as a Value (build it with json!) and serializes it.
Previous: Shared state: an in-memory store Next: The same API in axum