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() -> StatusCoderes.header(name) -> Option<&str>,res.headers()res.text() -> String,res.bytes() -> &Bytesres.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 backFixtures
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.