DocsWorking with Rahti
Testing
The router your tests drive is the router the server runs — it is ordinary Rust, generated at build time. There is nothing to mock and no test server to start.
Where tests live
An application's integration tests are cohesive area modules under src/tests/, collected by mod.rs into one library test binary. That preserves access to crate::routes, crate::db and everything else the application declares without letting one test file grow without bound.
src/tests/
├── mod.rs
├── support.rs
├── pages.rs
├── rpc.rs
└── database.rs # only when the project has a database// src/lib.rs — one collector keeps every area in one library test binary.
#[cfg(test)]
#[path = "tests/mod.rs"]
mod app_tests;//! The application test suite, grouped by cohesive area.
mod support;
use support::*;
mod pages;
mod rpc;Group by behavior — pages, RPCs, authentication, uploads — rather than creating one file per individual test. Keep only genuinely shared setup and request helpers in support.rs; feature-specific helpers stay beside their tests.
Driving the generated router
A test builds the router and sends it a request. Nothing is stubbed — the build scanned src/app/, the macro compiled the markup, and what comes back is the finished page.
//! Shared infrastructure for the application test areas.
use axum::body::Body;
use axum::http::Request;
pub(super) use axum::http::StatusCode;
use http_body_util::BodyExt;
use tower::ServiceExt;
/// GET a URL through the real router.
pub(super) async fn get(url: &str) -> (StatusCode, String) {
setup().await;
let res = crate::routes::router()
.oneshot(Request::builder().uri(url).body(Body::empty()).unwrap())
.await
.expect("the router answered");
let status = res.status();
let bytes = res.into_body().collect().await.expect("a body").to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}A suite's one-time setup
Two things a real request has that a bare oneshot does not: a database connection, and an installed auth policy. Both are process-wide, so both belong in one lazily-initialised helper the request functions call.
// Called from the request helpers rather than from each test: pages that
// touch the database are reached by tests that are not about the database.
async fn setup() {
static ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
ONCE.get_or_init(|| async {
let path = std::env::temp_dir().join("my-app-tests.db");
let _ = std::fs::remove_file(&path);
let url = format!(
"sqlite://{}?mode=rwc",
path.display().to_string().replace('\\', "/")
);
crate::db::connect_to(&url).await.expect("a test database");
crate::db::migrate().await.expect("the migrations");
// Install the same policy `initialize_application` installs.
rahti::auth::configure(crate::auth::settings());
})
.await;
}Testing an rpc
An rpc is a POST to its own page's URL with the function named in a header, so it needs no special harness. A suite that signs in and then calls a protected rpc must carry both cookies — the session and the CSRF token — from one response to the next request, the way a browser would.
// An rpc is a POST to its page's URL, with the function named in a header.
pub(super) async fn rpc(url: &str, name: &str, payload: &str) -> (StatusCode, String) {
let request = Request::builder()
.method("POST")
.uri(url)
.header("content-type", "application/json")
.header("X-PP-Function", name)
.body(Body::from(payload.to_string()))
.unwrap();
// …drive it through `crate::routes::router()` as above.
}What is worth asserting
| Change | What to cover |
|---|---|
| A page or a layout | Status, and a distinctive piece of the rendered markup. The manifest is the build's own account of what shipped — assert against it when the question is "did this route register". |
| An rpc | Success, malformed payloads, missing values, wrong types, and the limits. Test the failures: the happy path is the one that gets exercised by hand anyway. |
| Routing or configuration | Inspect the regenerated src/routes.rs and .rahti/manifest.json in the diff, and assert the URLs answer. |
| Authentication | That a private route redirects while signed out, that an auth route redirects while signed in, and that a protected rpc answers 401 rather than HTML. |
| A database change | Module wiring, migration order in the Migrator, and the queries against a real SQLite file. |
cargo test
cargo test --lib app_tests::rpc
cargo test --lib app_tests::pages::the_home_page_renders -- --exactWhen the runtime bundle changes
Replacing public/js/pp-reactive-v2.min.js is not covered by cargo test, so verify it by hand: that both copies of the bundle are byte-identical, that public/js/main.js still mounts the exposed global, that representative routes keep their bindings and scripts, and that a deliberate browser warning still reaches .rahti/dev.log.
