Test Client

Dispatch HTTP requests directly through your Application without opening a TCP socket.

TestClient<A> wraps any type that implements Application and dispatches requests through Application::execute in-process. No network, no port binding, no spawned threads — tests run as fast as plain function calls.

Creating a client

use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::test_client::TestClient;

let client = TestClient::new(App::new());

Every request is dispatched on a synthetic 127.0.0.1:12345 → 127.0.0.1:7878 connection. Pass any Application implementation — App, AppWithState, WithMiddleware, McpServer, or your own type.

State-aware applications

use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::test_client::TestClient;
use rust_web_server::routes;

struct AppState { counter: u32 }

let state = Arc::new(AppState { counter: 42 });
let app = routes! {
    App::with_state(Arc::clone(&state)),
    GET "/count" => |_req, _params, _conn, s: &Arc<AppState>| {
        // ... build response
    },
};
let client = TestClient::new(app);

Building requests

Each HTTP method has a corresponding builder method on TestClient:

client.get("/users")
client.post("/users")
client.put("/users/1")
client.patch("/users/1")
client.delete("/users/1")
client.options("/users")

All return a TestRequest that you configure with chained builder calls before calling .send().

Adding headers

let resp = client.get("/api/data")
    .header("Authorization", "Bearer my-token")
    .header("Accept", "application/json")
    .send();

Setting the body

// Raw bytes
let resp = client.post("/upload")
    .header("Content-Type", "application/octet-stream")
    .body_bytes(vec![0x89, 0x50, 0x4E, 0x47])
    .send();

// UTF-8 text
let resp = client.post("/echo")
    .header("Content-Type", "text/plain")
    .body_text("hello world")
    .send();

// JSON (set Content-Type yourself)
let resp = client.post("/users")
    .header("Content-Type", "application/json")
    .body_text(r#"{"name":"Alice","email":"alice@example.com"}"#)
    .send();

Reading the response

.send() returns a TestResponse:

let resp = client.get("/healthz").send();

resp.status()        // i16 — e.g. 200
resp.reason()        // &str — e.g. "OK"
resp.is_success()    // bool — true when 200–299
resp.body_text()     // &str — panics if body is not valid UTF-8
resp.body_bytes()    // &[u8] — raw body
resp.header("content-type")   // Option<&str> — case-insensitive lookup
resp.headers()       // &[Header] — all response headers

Testing middleware

Wrap any application with middleware before handing it to TestClient:

use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::request_id::RequestIdLayer;
use rust_web_server::test_client::TestClient;

let app = App::new().wrap(RequestIdLayer::new());
let client = TestClient::new(app);

// A request without an ID gets a fresh one on the response
let resp = client.get("/healthz").send();
assert!(resp.header("x-request-id").is_some());

// An incoming ID is echoed back unchanged
let resp = client.get("/healthz").header("X-Request-Id", "abc-123").send();
assert_eq!(Some("abc-123"), resp.header("x-request-id"));

RateLimitLayer (in middleware) uses the process-wide rate_limit::global() limiter keyed by client IP, and every TestClient request comes from 127.0.0.1 — so rate-limit tests share one budget across the whole test run.

Complete CRUD test suite

#[cfg(test)]
mod tests {
    use rust_web_server::app::App;
    use rust_web_server::core::New;
    use rust_web_server::test_client::TestClient;

    // `App::new()` has no `/users` routes: build your own app here (and
    // change the return type) — the `/users` tests below assume it has them.
    fn make_client() -> TestClient<App> {
        TestClient::new(App::new())
    }

    #[test]
    fn health_check_returns_200() {
        let client = make_client();
        let resp = client.get("/healthz").send();
        assert_eq!(200, resp.status());
    }

    #[test]
    fn unknown_route_returns_404() {
        let client = make_client();
        let resp = client.get("/does-not-exist").send();
        assert_eq!(404, resp.status());
    }

    #[test]
    fn post_creates_resource() {
        let client = make_client();
        let resp = client
            .post("/users")
            .header("Content-Type", "application/json")
            .body_text(r#"{"name":"Alice","email":"alice@example.com"}"#)
            .send();
        assert_eq!(201, resp.status());
        assert!(resp.header("location").is_some());
    }

    #[test]
    fn get_returns_json_content_type() {
        let client = make_client();
        let resp = client
            .get("/users/1")
            .header("Accept", "application/json")
            .send();
        assert_eq!(200, resp.status());
        let ct = resp.header("content-type").unwrap_or("");
        assert!(ct.contains("application/json"), "content-type was: {ct}");
    }

    #[test]
    fn put_updates_resource() {
        let client = make_client();
        let resp = client
            .put("/users/1")
            .header("Content-Type", "application/json")
            .body_text(r#"{"name":"Alice Smith"}"#)
            .send();
        assert_eq!(200, resp.status());
    }

    #[test]
    fn delete_removes_resource() {
        let client = make_client();
        let resp = client.delete("/users/1").send();
        assert!(resp.status() == 200 || resp.status() == 204);
    }

    #[test]
    fn body_text_is_accessible() {
        let client = make_client();
        let resp = client.get("/healthz").send();
        let body = resp.body_text();
        // body is a &str; check it is non-empty or contains expected content
        assert!(!body.is_empty() || resp.status() == 200);
    }
}

Isolated CORS and security-header tests

By default, App::new() reads CORS, CSP, and other settings from RWS_CONFIG_* environment variables on each request. That means tests which set those env vars can race against each other when cargo test runs in parallel — and any test that calls override_environment_variables_from_config must hold test_env::lock() for its duration.

For tests that only verify header behavior, the cleaner approach is App::with_config:

use rust_web_server::app::App;
use rust_web_server::server_config::ServerConfig;
use rust_web_server::test_client::TestClient;
use rust_web_server::header::Header;

#[test]
fn cors_denied_for_unlisted_origin() {
    // No env writes → no test_env::lock() → runs safely in parallel.
    let config = ServerConfig {
        cors_allow_all: false,
        cors_allow_origins: String::new(),
        ..ServerConfig::default()
    };
    let client = TestClient::new(App::with_config(config));

    let resp = client
        .options("/static/file.png")
        .header(Header::_ORIGIN, "https://evil.example.com")
        .send();

    assert!(resp.header("access-control-allow-origin").is_none());
}

#[test]
fn cors_passes_for_listed_origin() {
    let config = ServerConfig {
        cors_allow_all: false,
        cors_allow_origins: "https://trusted.example.com".to_string(),
        cors_allow_credentials: "true".to_string(),
        ..ServerConfig::default()
    };
    let client = TestClient::new(App::with_config(config));

    let resp = client
        .options("/static/file.png")
        .header(Header::_ORIGIN, "https://trusted.example.com")
        .header(Header::_ACCESS_CONTROL_REQUEST_METHOD, "GET")
        .send();

    assert_eq!(
        "https://trusted.example.com",
        resp.header("access-control-allow-origin").unwrap_or("")
    );
}

App::with_config(config) pins the app to a fixed ServerConfig for the lifetime of the App instance — SIGHUP and POST /admin/config/reload do not affect it. App::new() (no pinned config) continues to read env vars per request, which is correct for production and for tests that specifically verify hot-reload behavior.

AppWithState, AsyncAppWithState (requires http2), and ConfigDrivenApp (config-driven proxy) each fall through to a built-in App for any request their own routes/rules don't handle. That fallback reads env vars per request by default, same as App::new() — call .with_config(ServerConfig) on any of them for the same parallel-test-safe pinning:

use rust_web_server::state::AppWithState;
use rust_web_server::server_config::ServerConfig;
use rust_web_server::test_client::TestClient;

let config = ServerConfig { cors_allow_all: false, cors_allow_origins: String::new(), ..ServerConfig::default() };

let app = AppWithState::new(())
    .with_config(config)
    .get("/version", |_req, _params, _conn, _state| rust_web_server::response::Response::new());
let client = TestClient::new(app);
No TCP overhead

Because TestClient bypasses the network stack entirely, tests do not need #[tokio::test], async, or any port allocation. They run in plain #[test] functions and complete in microseconds.

Error responses

If Application::execute returns Err(String), TestClient converts it to a synthetic 500 Internal Server Error response whose body is the error message. This means error paths are testable with the same resp.status() assertions.

Edit this page on GitHub