← Table of contents

9.2 Proof at the Port

A craftsman who knows how to check a piece by hand checks it the way a craftsman does. A visitor checks differently: they arrive with business, knock on the door, and wait for an answer - the way everyone knocks. Between those two kinds of checking lies an entire class of errors that the craftsman misses precisely because he is the craftsman.

What You Need

To test HTTP routes, you need to send a request and check the response. And for a test to call router(), it must be importable. The solution is standard: add crates/api/src/lib.rs. When a package has both lib.rs and main.rs, Cargo builds both

The goal is to test the router and handlers, not the network stack. Axum provides oneshot from tower::ServiceExt for this - send a request directly into the router and receive a response, no server, no port, no network. The response is the same one a real client would get: the router processes the request identically regardless of how it arrived.

This is the approach recommended by the axum documentation for testing handlers.

The Build

Create crates/api/src/lib.rs:

pub mod routes;

In crates/api/src/main.rs, remove the mod routes; and use routes::{...} lines - the module now belongs to the library crate. Replace them with one import from tq_api:

use axum::{Router, routing::get};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, SharedStore, router};
use tq_core::config::Config;
use tq_core::persistence;
use tq_core::store::TaskStore;

In tests/Cargo.toml, add a new target and dependencies. Note [dev-dependencies] rather than [dependencies] - these packages compile only for tests and do not end up in the release binary:

[[test]]
name = "api"
path = "tests/api.rs"

[dev-dependencies]
tq-core    = { path = "../crates/core" }
tq-api     = { path = "../crates/api" }
axum       = "0.8"
tower      = { version = "0.5", features = ["util"] }
tempfile   = "3"
tokio      = { version = "1", features = ["full"] }
serde_json = "1"

To pick the latest versions at the time of reading rather than editing by hand, use cargo add with the --dev flag. tower must be added separately - because of the util feature:

cargo add --dev axum tempfile tokio serde_json -p tq-tests
cargo add --dev tower --features util -p tq-tests

The versions in the example above are tested - they work together.

Now tests/tests/api.rs. Two helper functions reduce repetition - they carry no logic, only cut down noise:

use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, router};
use tq_core::store::TaskStore;
use tower::ServiceExt;

fn make_state() -> (AppState, tempfile::TempDir) {
    let dir = tempfile::tempdir().unwrap();
    let state = AppState {
        store: Arc::new(Mutex::new(TaskStore::new(vec![]))),
        data_path: dir.path().join("tasks.json"),
    };
    (state, dir)
}

async fn body_json(res: axum::response::Response) -> Value {
    let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
    serde_json::from_slice(&bytes).unwrap()
}

make_state() creates a fresh store for each test. state.clone() inside a test is a cheap Arc clone: all clones look at the same store, so multiple oneshot calls within one test share the same data.

#[tokio::test]
async fn add_task_returns_task() {
    let (state, _dir) = make_state();

    let res = router(state)
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/tasks")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"title":"Buy coffee"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::OK);
    let body = body_json(res).await;
    assert_eq!(body["title"], "Buy coffee");
    assert_eq!(body["status"], "Todo");
}

#[tokio::test]
async fn list_returns_added_tasks() {
    let (state, _dir) = make_state();

    for json in [r#"{"title":"Buy coffee"}"#, r#"{"title":"Write tests"}"#] {
        router(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/tasks")
                    .header("content-type", "application/json")
                    .body(Body::from(json))
                    .unwrap(),
            )
            .await
            .unwrap();
    }

    let res = router(state)
        .oneshot(Request::builder().uri("/tasks").body(Body::empty()).unwrap())
        .await
        .unwrap();

    let tasks: Vec<Value> = serde_json::from_slice(
        &axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(),
    )
    .unwrap();
    assert_eq!(tasks.len(), 2);
}

#[tokio::test]
async fn done_marks_task_complete() {
    let (state, _dir) = make_state();

    let res = router(state.clone())
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/tasks")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"title":"Buy coffee"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let id = body_json(res).await["id"].as_u64().unwrap();

    let res = router(state)
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri(format!("/tasks/{id}/done"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(body_json(res).await["status"], "Done");
}

#[tokio::test]
async fn get_unknown_task_returns_404() {
    let (state, _dir) = make_state();

    let res = router(state)
        .oneshot(Request::builder().uri("/tasks/999").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

The Result

$ make ci
...
     Running tests/api.rs (target/debug/deps/api-...)

running 4 tests
test add_task_returns_task ... ok
test done_marks_task_complete ... ok
test get_unknown_task_returns_404 ... ok
test list_returns_added_tasks ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The port now proves itself.

The complete tq code for this chapter is in 9-shippable/02-proof-at-the-port/.


Lore: oneshot and Tower

Axum did not invent handler testing - it inherited it from Tower. In Tower, any service can be tested through ServiceExt::oneshot: one request, one response, no network stack. This works because Router implements the Service<Request> trait - the same interface a real HTTP server uses. The test reaches the same code the production client does, just without the TCP layer in between.

When a test is more complex - for example, you need to keep a connection alive or check behavior under load - a real server on port 0 is spun up. For verifying that routes and handlers are correct, oneshot is sufficient and faster.