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. Becausesrc/note.rsandsrc/service.rsdo 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 withtower::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:
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
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
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
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.
//! 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 ;
use MemoryCache;
use RateLimitConfig;
use ;
use ;
use ;
use ServiceExt;
async
async
async
async
async
async
How the pieces work:
oneshotsends one request through the router and returns the response. It needstower::ServiceExt, which is whytoweris a dev-dependency. The router is cloned between calls becauseoneshotconsumes the service.app(capacity, limit)calls the samebuild_routerthatmaincalls, so the tests exercise the real body limit and rate-limit layer instead of a simplified router.ConnectInfois the non-obvious part. In production,HttpHostusesinto_make_service_with_connect_info, so each request carries its peer address, and the limiter'sby_ipsubject reads that extension. Without it an in-process request has no subject and the limiter rejects it with500. 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:
| Test | Proves |
|---|---|
create_then_get_round_trips | The happy path: 201 with the generated id, then 200 by id |
blank_text_is_a_validation_problem_with_a_field | Validation surfaces as 422 application/problem+json with the contract's code and field |
malformed_json_and_bad_content_type_are_transport_problems | Extractors reject bad input with http.json / http.content_type before the handler |
unknown_fields_paths_and_routes_are_rejected | deny_unknown_fields, missing notes (note.not_found), and unknown routes (http.not_found) |
requests_over_the_limit_get_429_with_retry_after | The 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:
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.