ESC
Type to search...

Testing

Integration testing with TestClient

Rapina ships a TestClient that spins up a real HTTP server on a random port for each test. You write tests with #[tokio::test], make actual HTTP requests, and assert on the responses. No mocking, no faking — the full middleware stack runs exactly as it would in production.

Setup

TestClient::new(app) takes a Rapina builder and starts a background server. The server shuts down automatically when the client is dropped.

use rapina::prelude::*;
use rapina::testing::TestClient;
use http::StatusCode;

#[tokio::test]
async fn test_hello() {
    let app = Rapina::new()
        .with_introspection(false)
        .router(Router::new().route(http::Method::GET, "/", |_, _, _| async {
            "Hello, World!"
        }));

    let client = TestClient::new(app).await;
    let response = client.get("/").send().await;

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.text(), "Hello, World!");
}

Call .with_introspection(false) in tests to disable the built-in /__rapina/routes endpoint and keep your test output clean.

Making requests

TestClient exposes .get(), .post(), .put(), .patch(), and .delete() — each returns a builder you chain before calling .send().await.

// GET
let resp = client.get("/users/42").send().await;

// POST with JSON body
let resp = client
    .post("/users")
    .json(&serde_json::json!({ "name": "Alice", "email": "alice@example.com" }))
    .send()
    .await;

// PUT with custom header
let resp = client
    .put("/users/42")
    .header("authorization", "Bearer eyJ...")
    .json(&serde_json::json!({ "name": "Alice Updated" }))
    .send()
    .await;

// DELETE
let resp = client.delete("/users/42").send().await;

Request builder API

MethodDescription
.header(key, value)Adds a request header
.json(&T)Serializes T as JSON and sets Content-Type: application/json
.form(&T)URL-encodes T and sets Content-Type: application/x-www-form-urlencoded
.body(impl Into<Bytes>)Sets raw body bytes
.send()Sends the request, returns TestResponse

Reading responses

Every .send().await returns a TestResponse with these methods:

MethodReturn typeDescription
.status()StatusCodeHTTP status code
.text()StringBody as UTF-8 text
.json::<T>()TDeserialize body as JSON (panics on failure)
.try_json::<T>()Result<T, serde_json::Error>Deserialize body as JSON (returns error)
.headers()&HeaderMapResponse headers
.bytes()&BytesRaw body bytes
#[derive(serde::Deserialize)]
struct User {
    name: String,
    email: String,
}

let resp = client.get("/users/1").send().await;
assert_eq!(resp.status(), StatusCode::OK);

let user: User = resp.json();
assert_eq!(user.name, "Alice");

Use .try_json() when you want to handle deserialization errors gracefully instead of panicking.

Testing with authentication

Rapina routes are protected by default when auth is enabled. Set up with_auth() on the app, then test both the rejection and the authenticated path.

#[tokio::test]
async fn test_protected_route_rejects_anonymous() {
    let auth_config = AuthConfig::new("test-secret", 3600);

    let app = Rapina::new()
        .with_introspection(false)
        .with_auth(auth_config)
        .discover();

    let client = TestClient::new(app).await;

    // No token — should be rejected
    let resp = client.get("/protected-endpoint").send().await;
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

To test an authenticated request, create a token with AuthConfig::create_token() and pass it as a Bearer header:

#[tokio::test]
async fn test_protected_route_with_token() {
    let auth_config = AuthConfig::new("test-secret", 3600);
    let token = auth_config.create_token("user-123").unwrap();

    let app = Rapina::new()
        .with_introspection(false)
        .with_auth(auth_config)
        .discover();

    let client = TestClient::new(app).await;

    let resp = client
        .get("/protected-endpoint")
        .header("authorization", &format!("Bearer {}", token))
        .send()
        .await;

    assert_eq!(resp.status(), StatusCode::OK);
}

Routes marked #[public] bypass auth entirely:

#[public]
#[get("/health")]
async fn health() -> &'static str {
    "ok"
}

#[tokio::test]
async fn test_public_route_needs_no_token() {
    let auth_config = AuthConfig::new("test-secret", 3600);

    let app = Rapina::new()
        .with_introspection(false)
        .with_auth(auth_config)
        .discover();

    let client = TestClient::new(app).await;
    let resp = client.get("/health").send().await;
    assert_eq!(resp.status(), StatusCode::OK);
}

Testing with middleware

The full middleware stack runs during tests, so you can assert on side effects like response headers.

use rapina::middleware::{TraceIdMiddleware, TRACE_ID_HEADER};

#[tokio::test]
async fn test_trace_id_is_added() {
    let app = Rapina::new()
        .with_introspection(false)
        .middleware(TraceIdMiddleware::new())
        .router(Router::new().route(http::Method::GET, "/", |_, _, _| async { "ok" }));

    let client = TestClient::new(app).await;

    let resp1 = client.get("/").send().await;
    let resp2 = client.get("/").send().await;

    // Every response gets a trace ID header
    let id1 = resp1.headers().get(TRACE_ID_HEADER).unwrap().to_str().unwrap();
    let id2 = resp2.headers().get(TRACE_ID_HEADER).unwrap().to_str().unwrap();

    assert_eq!(id1.len(), 36); // UUID v4
    assert_ne!(id1, id2);      // unique per request
}

Body limit middleware:

use rapina::middleware::BodyLimitMiddleware;

#[tokio::test]
async fn test_body_limit_rejects_large_payload() {
    let app = Rapina::new()
        .with_introspection(false)
        .middleware(BodyLimitMiddleware::new(64)) // 64 bytes max
        .router(
            Router::new().route(http::Method::POST, "/upload", |_, _, _| async { "ok" }),
        );

    let client = TestClient::new(app).await;

    // Small payload — accepted
    let resp = client.post("/upload").body("small").send().await;
    assert_eq!(resp.status(), StatusCode::OK);
}

Testing error responses

All Rapina errors return a consistent JSON envelope with error.code, error.message, and a trace_id. Parse it with serde_json::Value:

#[tokio::test]
async fn test_error_response_format() {
    let app = Rapina::new()
        .with_introspection(false)
        .router(
            Router::new().route(http::Method::GET, "/users/:id", |_, _, _| async {
                Error::not_found("user not found")
            }),
        );

    let client = TestClient::new(app).await;
    let resp = client.get("/users/999").send().await;

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);

    let json: serde_json::Value = resp.json();
    assert_eq!(json["error"]["code"], "NOT_FOUND");
    assert_eq!(json["error"]["message"], "user not found");
    assert!(json["trace_id"].is_string());
}

Errors with details include them under error.details:

let app = Rapina::new()
    .with_introspection(false)
    .router(
        Router::new().route(http::Method::POST, "/users", |_, _, _| async {
            Error::validation("invalid input").with_details(serde_json::json!({
                "field": "email",
                "reason": "invalid format"
            }))
        }),
    );

let client = TestClient::new(app).await;
let resp = client.post("/users").send().await;

assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);

let json: serde_json::Value = resp.json();
assert_eq!(json["error"]["details"]["field"], "email");

Complete example

A full CRUD test for a small in-memory API:

use rapina::prelude::*;
use rapina::testing::TestClient;
use http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
struct Todo {
    id: u64,
    title: String,
}

type TodoStore = Arc<Mutex<Vec<Todo>>>;

#[tokio::test]
async fn test_todo_crud() {
    let store: TodoStore = Arc::new(Mutex::new(Vec::new()));

    let app = Rapina::new()
        .with_introspection(false)
        .state(store.clone())
        .router(
            Router::new()
                .route(http::Method::POST, "/todos", |req, _, state: Arc<AppState>| async move {
                    use http_body_util::BodyExt;
                    let body = req.into_body().collect().await.unwrap().to_bytes();
                    let todo: Todo = serde_json::from_slice(&body).unwrap();
                    let store = state.get::<TodoStore>().unwrap();
                    store.lock().await.push(todo.clone());
                    (StatusCode::CREATED, Json(todo))
                })
                .route(http::Method::GET, "/todos/:id", |_, params, state: Arc<AppState>| async move {
                    let id: u64 = params.get("id").unwrap().parse().unwrap();
                    let store = state.get::<TodoStore>().unwrap();
                    let todos = store.lock().await;
                    match todos.iter().find(|t| t.id == id) {
                        Some(todo) => Json(todo.clone()).into_response(),
                        None => Error::not_found("todo not found").into_response(),
                    }
                })
                .route(http::Method::DELETE, "/todos/:id", |_, params, state: Arc<AppState>| async move {
                    let id: u64 = params.get("id").unwrap().parse().unwrap();
                    let store = state.get::<TodoStore>().unwrap();
                    let mut todos = store.lock().await;
                    todos.retain(|t| t.id != id);
                    StatusCode::NO_CONTENT
                }),
        );

    let client = TestClient::new(app).await;

    // Create
    let resp = client
        .post("/todos")
        .json(&Todo { id: 1, title: "Write tests".into() })
        .send()
        .await;
    assert_eq!(resp.status(), StatusCode::CREATED);
    let created: Todo = resp.json();
    assert_eq!(created.title, "Write tests");

    // Read
    let resp = client.get("/todos/1").send().await;
    assert_eq!(resp.status(), StatusCode::OK);
    let fetched: Todo = resp.json();
    assert_eq!(fetched, created);

    // Not found
    let resp = client.get("/todos/999").send().await;
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);

    // Delete
    let resp = client.delete("/todos/1").send().await;
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    // Confirm deletion
    let resp = client.get("/todos/1").send().await;
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}