Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Testing

Ultimo ships first-class testing utilities behind the testing feature. The TestClient drives your app in-process — no socket, no ports, fully deterministic and fast.

Enable

Add ultimo with the testing feature as a dev-dependency:

[dev-dependencies]
ultimo = { version = "0.6", features = ["testing"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

TestClient

use ultimo::testing::TestClient;
use ultimo::{Context, Ultimo};
 
fn app() -> Ultimo {
    let mut app = Ultimo::new_without_defaults();
    app.get("/hello", |ctx: Context| async move { ctx.text("hi").await });
    app.post("/echo", |ctx: Context| async move {
        let body: serde_json::Value = ctx.req.json().await.unwrap_or_default();
        ctx.json(body).await
    });
    app
}
 
#[tokio::test]
async fn hello_works() {
    let client = TestClient::new(app());
 
    let res = client.get("/hello").send().await;
 
    res.assert_ok();            // 200
    assert_eq!(res.text(), "hi");
}

Building requests

The builder from client.get/post/put/delete/patch/head/options(path) (or client.request(method, path)) is fluent:

let res = client
    .post("/users")
    .bearer("my-token")                          // Authorization: Bearer my-token
    .header("x-trace", "abc")
    .query(&[("page", "2"), ("q", "ada")])       // ?page=2&q=ada
    .json(&serde_json::json!({ "name": "Ada" })) // sets content-type: application/json
    .send()
    .await;

Other body setters: .body(bytes) and .text("…").

Inspecting responses

All accessors are synchronous (the body is already buffered):

  • res.status() -> StatusCode
  • res.header(name) -> Option<&str>, res.headers()
  • res.text() -> String, res.bytes() -> &Bytes
  • res.json::<T>() -> T

Chainable assertions (panic with a clear message; return &Self):

res.assert_status(201)
   .assert_header("content-type", "application/json")
   .assert_json(&serde_json::json!({ "id": 1 }));

Also assert_ok() (200) and assert_status_is_success() (2xx).

Macros

ultimo::assert_status!(res, 200);
ultimo::assert_json_eq!(res.json::<serde_json::Value>(), serde_json::json!({ "ok": true }));

Testing middleware in isolation

Build a Context with test_context() and run a single middleware with run_middleware (construct the middleware like the built-ins — Arc::new(|ctx, next| Box::pin(async move { … }))):

use std::sync::Arc;
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::testing::{run_middleware, test_context};
use ultimo::Context;
 
fn auth() -> BoxedMiddleware {
    Arc::new(|ctx: Context, next: Next| {
        Box::pin(async move {
            if ctx.req.header("authorization").is_none() {
                return ultimo::response::ResponseBuilder::new()
                    .status(401)
                    .text("unauthorized")
                    .build();
            }
            next(ctx).await
        })
    })
}
 
#[tokio::test]
async fn blocks_unauthenticated() {
    let ctx = test_context().path("/private").build();
    let res = run_middleware(auth(), ctx, |ctx| async move { ctx.text("ok").await })
        .await
        .unwrap();
    assert_eq!(res.status(), 401);
}

Database tests with rollback

Enable testing plus a sqlx-* backend. with_test_transaction runs your closure inside a transaction that is always rolled back — tests never mutate state. Return a boxed future (mirrors sqlx's Pool::transaction):

use ultimo::testing::with_test_transaction;
 
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY)").execute(&pool).await.unwrap();
 
with_test_transaction(&pool, |tx| {
    Box::pin(async move {
        sqlx::query("INSERT INTO t (id) VALUES (1)")
            .execute(&mut **tx)
            .await
            .unwrap();
        Ok(())
    })
})
.await
.unwrap();
// the insert was rolled back

Fixtures

use ultimo::testing::load_fixture;
 
#[derive(serde::Deserialize)]
struct User { id: u32, name: String }
 
let user: User = load_fixture("tests/fixtures/user.json");

Implement the Fixture trait (setup/teardown) for seed/cleanup lifecycles.

Without the test client

Ultimo::oneshot(req) dispatches a buffered http::Request through the app in-process and returns the Response — the seam TestClient is built on, handy for lower-level tests or embedding.