Skip to content
loco
v1.0
★ 6.9k Get started

Write request (controller) tests

Goal: call your app’s HTTP endpoints from a test — without a real bound port — and assert on the response, using Loco’s request/boot_test helpers over axum-test.

1. Enable the testing feature

Request tests need the testing Cargo feature (pulls in axum-test, scraper, and tree-fs). Add it to your dev-dependencies — it’s already there in apps generated by loco new:

[dev-dependencies]
loco-rs = { version = "*", features = ["testing"] }
serial_test = "*"
insta = { version = "*", features = ["redactions"] }

2. Write a request test with request::<App, _, _>()

request boots your app in Environment::Test and gives you an in-memory TestServer plus the app’s AppContext — no DB is created:

use demo::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn can_get_notes() {
request::<App, _, _>(|request, _ctx| async move {
let res = request.get("/api/notes/").await;
assert_eq!(res.status_code(), 200);
})
.await;
}

The callback receives (TestServer, AppContext)request (an axum-test TestServer) drives HTTP calls (.get, .post, .json(...), etc.), and ctx gives you the same AppContext your controllers see (DB connection, config, mailer, …) — see the AppContext reference.

Mark tests #[serial] (from the serial_test crate) whenever they share app-level or DB-level state with other tests, so they don’t run concurrently against the same fixtures.

If your test needs a real, freshly created database (registering a user, then reading it back), use request_with_create_db instead — see DB model tests for the full story on DB-backed tests and cleanup.

3. Assert on the response

request/response come straight from axum-test — the common assertions:

let response = request.post("/api/auth/login").json(&payload).await;
assert_eq!(response.status_code(), 200);
response.assert_json(&serde_json::json!({ "token": "..." }));
let body: LoginResponse = serde_json::from_str(&response.text()).unwrap();

For HTML/HTMX responses, parse response.text() with the HTML selector assertions (assert_css_exists, assert_css_eq, select, …) instead of string-matching raw markup.

4. Customize the request with RequestConfigBuilder

request uses a default RequestConfig (no saved cookies, default_content_type: "application/json"). To change that — e.g. to keep cookies across calls in the same test (cookie/session auth flows) — build a custom config and use request_with_config:

use loco_rs::testing::prelude::*;
let config = RequestConfigBuilder::new()
.save_cookies(true)
.default_content_type("application/json")
.build();
request_with_config::<App, _, _>(config, |request, _ctx| async move {
// cookies set by one call are sent on subsequent calls in this closure
request.post("/session/login").json(&payload).await;
let res = request.get("/session/me").await;
assert_eq!(res.status_code(), 200);
})
.await;

RequestConfigBuilder methods: .save_cookies(bool), .default_content_type(impl Into<String>), .default_scheme(impl Into<String>), .build().

Gotcha: RequestConfig::default_scheme is not forwarded to axum-test’s underlying TestServerConfig — only default_content_type and save_cookies are. Setting .default_scheme(...) has no observable effect today; don’t rely on it to force https.

5. Reach for boot_test directly when you don’t need HTTP

request is a thin wrapper: it calls boot_test::<App>() to boot the app, then wraps the router in a TestServer. If you don’t need to go through HTTP at all — e.g. you’re testing a model or service function directly — call boot_test yourself and skip the server:

use demo::app::App;
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn test_something() {
let boot = boot_test::<App>().await.expect("failed to boot test app");
// use boot.app_context, e.g. boot.app_context.db, boot.app_context.config, ...
}

boot_test<H: Hooks>() is single-generic — it takes only your Hooks implementation (typically App), not a second Migrator type parameter. Signatures live in src/testing/request.rs:

FunctionUse when
boot_test::<App>()You need AppContext without a DB, and without HTTP.
boot_test_with_create_db::<App>()You need AppContext backed by a fresh, throwaway database (with-db only). See DB model tests.
boot_test_unique_port::<App>(port)You need the server actually bound to a TCP port (rare — most tests should use request/TestServer instead).

request/boot_test both boot with Environment::Test, so they read config/test.yaml — see configuration precedence for how the config folder and environment name are resolved.

Verify it

Terminal window
cargo test

A passing test prints the usual test result: ok from cargo test; a failing status-code or JSON assertion panics with axum-test’s standard diff/message.