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_schemeis not forwarded to axum-test’s underlyingTestServerConfig— onlydefault_content_typeandsave_cookiesare. Setting.default_scheme(...)has no observable effect today; don’t rely on it to forcehttps.
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:
| Function | Use 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
cargo testA 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.