You are reading the docs for rws 18.4 The latest release is 18.8. Read this page for the latest release.

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