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 go in src/tests/app.rs, included from main.rs as a module of the binary. That is what gives them crate::routes, crate::db and everything else the application declares.
// src/main.rs — the harness is a module of the binary, so it can reach
// `crate::routes` and everything else the application declares.
#[cfg(test)]
#[path = "tests/app.rs"]
mod app_tests;Follow that placement rather than creating ad hoc test binaries. The workspace has no JavaScript test suite: the browser runtime is a prebuilt asset, exercised through a browser rather than through cargo test.
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.
//! src/tests/app.rs — the app, driven through its generated router.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
/// GET a URL through the real router.
async fn get(url: &str) -> (StatusCode, String) {
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())
}
#[tokio::test]
async fn the_home_page_renders() {
let (status, body) = get("/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<h1"));
}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 ready() {
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());
crate::db::connect_to(&url).await.expect("a test database");
crate::db::migrate().await;
// The same call `main` makes: without it, the suite exercises
// private routes that nothing protects.
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.
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 # the whole suite
cargo test --bin my-app # just the binary's tests
cargo test the_home_page # one test by nameWhen 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.
