Test behavior at two boundaries

A successful HTTP response is only one part of application correctness. You also need to know that the service enforces its rules when called without HTTP, and that invalid input does not mutate state.

First test the operation

Construct the service directly and call the same operation your handler calls. For a create operation, check at least:

  1. Valid input returns the new value and makes it retrievable.
  2. Blank or otherwise invalid input returns a validation error.
  3. Failed validation leaves storage unchanged.
  4. Looking up an absent ID returns a not-found error.

The notes application's tests show this using its real memory-backed service. These tests do not need an HTTP server or JSON. That is the practical payoff of keeping HTTP-specific types out of the operation.

Then test the adapter

Axum's Router is a Tower service. You can submit an in-process request with tower::ServiceExt::oneshot instead of binding a TCP port. Here is a complete small adapter test; dependencies are iet-core, iet-http-axum, Axum 0.8, Serde with derive, Tokio with macros and a runtime, and Tower with util.

rust
use axum::{
    body::Body,
    http::{Request, StatusCode},
    routing::post,
};
use iet_core::{AppError, AppResult, ErrorKind, Validate};
use iet_http_axum::{Created, RestApi, ValidatedJson};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;

#[derive(Deserialize, Serialize)]
struct Input {
    text: String,
}

impl Validate for Input {
    fn validate(&self) -> AppResult<()> {
        if self.text.trim().is_empty() {
            return Err(AppError::new(
                ErrorKind::Validation,
                "note.text_required",
                "A note needs some text",
            ));
        }
        Ok(())
    }
}

async fn echo(ValidatedJson(input): ValidatedJson<Input>) -> Created<Input> {
    Created(input)
}

#[tokio::test]
async fn blank_text_is_rejected_before_the_handler() {
    let app = RestApi::new().route("/notes", post(echo)).finish(());
    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/notes")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"text":" "}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    assert_eq!(
        response.headers()["content-type"],
        "application/problem+json",
    );
}

This test was executed successfully against the current local framework crates; the validation input and configuration definitions on the adjacent reference pages were also compiled in that isolated check. This does not validate every snippet on the site.

This isolated test intentionally has no limiter or persistence. It tests the extractor's behavior, not deployment admission control or database correctness. The real application's router needs separate tests for its configured middleware.

For authenticated endpoints, add missing credentials, expired sessions, forbidden access, and cross-user resource access. A valid login followed by a successful request does not establish ownership isolation.

Use a real backend when testing backend behavior

An in-memory substitute cannot prove SQL predicates, transaction rollback, migration checksums, or database constraints. Run those against a disposable TimescaleDB instance. Avoid pointing destructive tests at a developer's shared database. The persistence guide explains where database sessions enter an operation.

Know what a passing command proves

Run the tutorial's cargo test --manifest-path command to test its application. That is different from cargo test --workspace at the repository root, which also exercises unrelated framework components and architecture rules. The current root core-architecture test has a known conflict with the boxed health-probe registry. A focused application test passing does not mean that full suite passed.

Likewise, a documentation-site build checks page syntax and links, not Rust code inside Markdown fences. Only examples explicitly accompanied by an executed compile/test command should be described as tested.

IET Framework documentation — built with the IET Framework.