Test the notes API

This page adds the tests for the application from Build it yourself. If you are using the checked-in reference at examples/handbook-notes/, the same tests are already there; the commands below name examples/my-notes/ to match the recipe. Both packages are named handbook-notes, so the imports are identical.

Testing happens at two levels, and the split matches the code's boundaries:

  • Unit tests live inside each module behind #[cfg(test)] mod tests. They call the contract, repository, and service directly, with no server. Because src/note.rs and src/service.rs do not import Axum, these tests cannot accidentally depend on HTTP.
  • HTTP tests live in tests/http.rs. They build the real router and call it in-process with tower::ServiceExt::oneshot, so they check parsing, validation, error mapping, and rate limiting without opening a port.

Add the blocks below to your copy, then run the suite at the end of this page.

Unit tests

Each of these modules ends with a #[cfg(test)] mod tests. The attribute means the module is compiled only for tests, so it costs nothing in the running binary. use super::*; brings the enclosing module's items into scope, which is why the tests can name private helpers.

The rules: src/note.rs

Append this to src/note.rs:

examples/my-notes/src/note.rs (tests)rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn blank_text_reports_a_field_error() {
        let error = CreateNote {
            text: "  \t".into(),
        }
        .validate()
        .unwrap_err();
        assert_eq!(error.kind, ErrorKind::Validation);
        assert_eq!(error.code, "note.text_required");
        assert_eq!(
            error.fields,
            vec![FieldError {
                path: "text".into(),
                code: "required".into(),
            }]
        );
    }

    #[test]
    fn non_blank_text_is_valid() {
        assert!(
            CreateNote {
                text: "note".into()
            }
            .validate()
            .is_ok()
        );
    }

    #[test]
    fn note_ids_start_at_one() {
        assert!(GetNote { id: 1 }.validate().is_ok());
        assert_eq!(
            GetNote { id: 0 }.validate().unwrap_err().code,
            "note.id_required"
        );
    }
}

These are ordinary #[test] functions because validate is synchronous. They pin down the exact error kind, code, and field the HTTP layer later renders. Changing the user-facing message would not break them; changing the contract would.

The store: src/memory.rs

examples/my-notes/src/memory.rs (tests)rust
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn ids_are_unique_and_increasing() {
        let store = MemoryNotes::new(4).unwrap();
        let first = store.insert("a".into()).await.unwrap();
        let second = store.insert("b".into()).await.unwrap();
        assert_ne!(first.id, second.id);
        assert!(second.id > first.id);
        assert_eq!(store.find(first.id).await.unwrap(), Some(first));
    }

    #[tokio::test]
    async fn capacity_is_enforced_and_zero_is_rejected() {
        assert!(MemoryNotes::new(0).is_err());
        let store = MemoryNotes::new(1).unwrap();
        store.insert("a".into()).await.unwrap();
        assert_eq!(
            store.insert("b".into()).await.unwrap_err().kind,
            ErrorKind::Unavailable
        );
        assert_eq!(store.len(), 1);
    }

    #[tokio::test]
    async fn missing_ids_return_nothing() {
        let store = MemoryNotes::new(2).unwrap();
        assert!(store.find(99).await.unwrap().is_none());
    }
}

The repository methods are asynchronous even though memory needs no await, so these use #[tokio::test]. The tests call MemoryNotes directly, which is why len() and is_empty() exist on the type: they make the store's state observable without reaching through the service.

The application behavior: src/service.rs

examples/my-notes/src/service.rs (tests)rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::memory::MemoryNotes;

    fn service(capacity: usize) -> NotesService<MemoryNotes> {
        NotesService::new(MemoryNotes::new(capacity).unwrap())
    }

    #[tokio::test]
    async fn create_validates_and_stores_trimmed_text() {
        let notes = service(8);
        let note = notes
            .create(
                &CallContext::default(),
                CreateNote {
                    text: "  hello  ".into(),
                },
            )
            .await
            .unwrap();
        assert_eq!(note.id, 1);
        assert_eq!(note.text, "hello");
    }

    #[tokio::test]
    async fn create_rejects_blank_text_without_storing() {
        let notes = service(8);
        let error = notes
            .create(&CallContext::default(), CreateNote { text: "   ".into() })
            .await
            .unwrap_err();
        assert_eq!(error.kind, ErrorKind::Validation);
        assert_eq!(error.code, "note.text_required");
        // The repository was never reached, so the note does not exist.
        assert!(
            notes
                .get(&CallContext::default(), GetNote { id: 1 })
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn get_reports_missing_notes() {
        let notes = service(8);
        let error = notes
            .get(&CallContext::default(), GetNote { id: 7 })
            .await
            .unwrap_err();
        assert_eq!(error.kind, ErrorKind::NotFound);
        assert_eq!(error.code, "note.not_found");
    }

    #[tokio::test]
    async fn full_store_reports_unavailable() {
        let notes = service(1);
        notes
            .create(&CallContext::default(), CreateNote { text: "one".into() })
            .await
            .unwrap();
        let error = notes
            .create(&CallContext::default(), CreateNote { text: "two".into() })
            .await
            .unwrap_err();
        assert_eq!(error.kind, ErrorKind::Unavailable);
        assert_eq!(error.code, "notes.capacity");
    }
}

service(capacity) builds a NotesService<MemoryNotes> for each test, so tests never share state. create_rejects_blank_text_without_storing is the important one: it proves validation happens inside the service, not only in the HTTP extractor, by showing a caller with no HTTP involved still gets the error and that nothing was stored. full_store_reports_unavailable exercises the capacity path that becomes 503 at the edge.

The settings: src/config.rs

examples/my-notes/src/config.rs (tests)rust
#[cfg(test)]
mod tests {
    use super::*;
    use iet_config::ConfigLoader;
    use std::ffi::OsString;

    fn env(entries: &[(&str, &str)]) -> iet_config_env::EnvSource {
        iet_config_env::EnvSource::from_iter(
            "NOTES",
            entries
                .iter()
                .map(|(key, value)| (OsString::from(key), OsString::from(value))),
        )
        .unwrap()
    }

    #[test]
    fn defaults_apply_when_nothing_is_set() {
        let config: NotesConfig = ConfigLoader::new(env(&[])).load().unwrap();
        assert_eq!(config.http_address.port(), 3012);
        assert_eq!(config.capacity, 1000);
    }

    #[test]
    fn supplied_values_override_defaults() {
        let config: NotesConfig = ConfigLoader::new(env(&[
            ("NOTES_HTTP_ADDRESS", "127.0.0.1:4010"),
            ("NOTES_CAPACITY", "25"),
        ]))
        .load()
        .unwrap();
        assert_eq!(config.http_address.port(), 4010);
        assert_eq!(config.capacity, 25);
    }

    #[test]
    fn malformed_values_fail_instead_of_falling_back() {
        assert!(
            ConfigLoader::new(env(&[("NOTES_CAPACITY", "0")]))
                .load::<NotesConfig>()
                .is_err()
        );
        assert!(
            ConfigLoader::new(env(&[("NOTES_HTTP_ADDRESS", "not-an-address")]))
                .load::<NotesConfig>()
                .is_err()
        );
    }
}

EnvSource::from_iter is the framework's deterministic test constructor: it builds a source from an in-memory list instead of reading the process environment, so tests can run in parallel and never mutate global state. The tests cover the three outcomes that matter: a default applies, a valid override wins, and a malformed override fails rather than falling back.

HTTP tests: tests/http.rs

Integration tests live in the tests/ directory and are compiled as separate crates that use the package's library (handbook_notes). This one drives the router without a network listener.

examples/my-notes/tests/http.rsrust
//! HTTP tests drive the real router in-process with `tower::ServiceExt::oneshot`.
//! No TCP listener is opened; the host's `ConnectInfo` is supplied by hand.

use axum::{
    Router,
    body::{Body, to_bytes},
    extract::ConnectInfo,
    http::{Request, StatusCode},
};
use iet_cache_memory::MemoryCache;
use iet_http_axum::RateLimitConfig;
use iet_rate_limit::{RateLimiter, RatePolicy};
use handbook_notes::{memory::MemoryNotes, routes::build_router, service::NotesService};
use std::{net::SocketAddr, time::Duration};
use tower::ServiceExt;

fn app(capacity: usize, limit: u64) -> Router {
    let notes = NotesService::new(MemoryNotes::new(capacity).unwrap());
    let limiter = RateLimiter::new(
        MemoryCache::<u64>::new(1024).unwrap(),
        RatePolicy::new("notes.test.ip", limit, Duration::from_secs(60)).unwrap(),
    );
    build_router(
        notes,
        limiter,
        RateLimitConfig::fixed(limit, Duration::from_secs(60)),
    )
    .unwrap()
}

fn request(method: &str, uri: &str, content_type: Option<&str>, body: &str) -> Request<Body> {
    let mut builder = Request::builder().method(method).uri(uri);
    if let Some(content_type) = content_type {
        builder = builder.header("content-type", content_type);
    }
    let mut request = builder.body(Body::from(body.to_owned())).unwrap();
    // The framework host installs ConnectInfo for each connection. Rate
    // limiting identifies the caller by peer address, so tests supply it too.
    request.extensions_mut().insert(ConnectInfo(
        "127.0.0.1:40000".parse::<SocketAddr>().unwrap(),
    ));
    request
}

async fn json(response: axum::response::Response) -> serde_json::Value {
    let bytes = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
    serde_json::from_slice(&bytes).unwrap()
}

#[tokio::test]
async fn create_then_get_round_trips() {
    let app = app(16, 1000);

    let created = app
        .clone()
        .oneshot(request(
            "POST",
            "/notes",
            Some("application/json"),
            r#"{"text":"buy milk"}"#,
        ))
        .await
        .unwrap();
    assert_eq!(created.status(), StatusCode::CREATED);
    let note = json(created).await;
    assert_eq!(note["id"], 1);
    assert_eq!(note["text"], "buy milk");

    let fetched = app
        .oneshot(request("GET", "/notes/1", None, ""))
        .await
        .unwrap();
    assert_eq!(fetched.status(), StatusCode::OK);
    assert_eq!(json(fetched).await["text"], "buy milk");
}

#[tokio::test]
async fn blank_text_is_a_validation_problem_with_a_field() {
    let app = app(16, 1000);
    let response = app
        .oneshot(request(
            "POST",
            "/notes",
            Some("application/json"),
            r#"{"text":"   "}"#,
        ))
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    assert_eq!(
        response.headers()["content-type"],
        "application/problem+json"
    );
    let problem = json(response).await;
    assert_eq!(problem["code"], "note.text_required");
    assert_eq!(problem["errors"][0]["path"], "text");
    assert_eq!(problem["errors"][0]["code"], "required");
}

#[tokio::test]
async fn malformed_json_and_bad_content_type_are_transport_problems() {
    let app = app(16, 1000);
    for (content_type, body, status, code) in [
        (
            "application/json",
            "not json",
            StatusCode::BAD_REQUEST,
            "http.json",
        ),
        (
            "text/plain",
            "hello",
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
            "http.content_type",
        ),
    ] {
        let response = app
            .clone()
            .oneshot(request("POST", "/notes", Some(content_type), body))
            .await
            .unwrap();
        assert_eq!(response.status(), status);
        assert_eq!(json(response).await["code"], code);
    }
}

#[tokio::test]
async fn unknown_fields_paths_and_routes_are_rejected() {
    let app = app(16, 1000);

    // `deny_unknown_fields` makes this a JSON data error, which the extractor
    // classifies as 422 rather than a 400 syntax error.
    let extra = app
        .clone()
        .oneshot(request(
            "POST",
            "/notes",
            Some("application/json"),
            r#"{"text":"x","extra":1}"#,
        ))
        .await
        .unwrap();
    assert_eq!(extra.status(), StatusCode::UNPROCESSABLE_ENTITY);
    assert_eq!(json(extra).await["code"], "http.json");

    let missing = app
        .clone()
        .oneshot(request("GET", "/notes/999", None, ""))
        .await
        .unwrap();
    assert_eq!(missing.status(), StatusCode::NOT_FOUND);
    assert_eq!(json(missing).await["code"], "note.not_found");

    let unknown = app
        .oneshot(request("GET", "/nope", None, ""))
        .await
        .unwrap();
    assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
    assert_eq!(json(unknown).await["code"], "http.not_found");
}

#[tokio::test]
async fn requests_over_the_limit_get_429_with_retry_after() {
    let app = app(16, 2);
    for _ in 0..2 {
        let response = app
            .clone()
            .oneshot(request(
                "POST",
                "/notes",
                Some("application/json"),
                r#"{"text":"note"}"#,
            ))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);
    }

    let denied = app
        .oneshot(request("GET", "/notes/1", None, ""))
        .await
        .unwrap();
    assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
    assert_eq!(denied.headers()["cache-control"], "no-store");
    assert!(denied.headers().contains_key("retry-after"));
}

How the pieces work:

  • oneshot sends one request through the router and returns the response. It needs tower::ServiceExt, which is why tower is a dev-dependency. The router is cloned between calls because oneshot consumes the service.
  • app(capacity, limit) calls the same build_router that main calls, so the tests exercise the real body limit and rate-limit layer instead of a simplified router.
  • ConnectInfo is the non-obvious part. In production, HttpHost uses into_make_service_with_connect_info, so each request carries its peer address, and the limiter's by_ip subject reads that extension. Without it an in-process request has no subject and the limiter rejects it with 500. The helper inserts a fixed peer address, which is also why each test builds a fresh router: the counter is keyed by that address.
  • json(response) reads the full body with a bound (16 * 1024) before parsing, mirroring the framework's bounded-body approach.

What each HTTP test locks down:

TestProves
create_then_get_round_tripsThe happy path: 201 with the generated id, then 200 by id
blank_text_is_a_validation_problem_with_a_fieldValidation surfaces as 422 application/problem+json with the contract's code and field
malformed_json_and_bad_content_type_are_transport_problemsExtractors reject bad input with http.json / http.content_type before the handler
unknown_fields_paths_and_routes_are_rejecteddeny_unknown_fields, missing notes (note.not_found), and unknown routes (http.not_found)
requests_over_the_limit_get_429_with_retry_afterThe named module policy denies the third request with 429 and a retry-after header

Run the tests

After adding the blocks above, run from the repository root:

sh
cargo test --manifest-path examples/my-notes/Cargo.toml

You should see the 13 unit tests and 5 HTTP tests pass:

running 13 tests
...
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

running 5 tests
...
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

What these tests do not cover

They are honest about the boundary. They do not open a socket, so they say nothing about TLS, connection limits, or real client IPs behind a proxy. They do not authenticate anyone. They do not test concurrent writes; the repository's lock is standard-library, but correctness under contention is a separate exercise. And they do not test the drain behavior of HttpHost, which is the framework crates' own responsibility.

For the design reasons behind the two layers, continue to The application model. For the error kinds used above, see Validation and errors.

IET Framework documentation — built with the IET Framework.