Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing

Arcature applications are tested the way Tower services are tested: build the router, drive it in-process, assert on the response. There is no socket, no port to allocate, and no teardown race.

Driving the router

Application::into_router hands back the composed Router. From there tower::ServiceExt::oneshot sends a single request through the whole router-level pipeline and returns the response.

use arcature::axum::Router;
use arcature::axum::body::Body;
use arcature::axum::http::{Request, Response};
use tower::ServiceExt as _;

async fn send(router: Router, request: Request<Body>) -> Response<Body> {
    router.oneshot(request).await.expect("infallible")
}

fn get(uri: &str) -> Request<Body> {
    Request::builder()
        .uri(uri)
        .body(Body::empty())
        .expect("request")
}

tower is not re-exported by Arcature, so a test crate using oneshot needs tower = { version = "0.5", features = ["util"] } under [dev-dependencies]. axum is re-exported, as arcature::axum.

This is the pattern the framework’s own tests/application.rs uses. It exercises stages 3 through 20 of the pipeline: everything the builder composes onto the router.

It does not exercise stages 1 and 2 – the dev proxy and the pre-routing proxy. Those are composed around the router as a service in run_with_state and serve, because they rewrite the URI before route selection. A test that needs them has to build the service, not the router.

Asserting layer order

Layer order is a contract, so the framework asserts it rather than only documenting it. The technique is a marker layer that appends its own name to a response header; the resulting header value spells out the order the layers actually ran in. If a future edit reorders the stack, the assertion fails with a diff a reader can act on, instead of a subtly different security posture nobody notices.

If your application depends on where its own .layer() calls sit relative to the framework’s, the same technique works: user layers are stage 18, inside everything the builder installs and outside the router.

Route tables

A route table is data, so it can be asserted without a request at all. The generated application ships exactly this as its smoke test:

use my_app::routes;

#[test]
fn home_route_is_registered() {
    let routes = routes::routes();
    assert_eq!(routes.url_for("home", &[]).unwrap(), "/");
}

url_for returns Err(Error::NotFound(..)) for a name that is not in the table, so a renamed route fails the test rather than silently producing a broken link at runtime.

Events

Dispatcher::recording() builds a dispatcher that remembers the names of the events it dispatched. was_dispatched(name) and dispatched_events() read that record back. Both return false / an empty vector on a dispatcher built with Dispatcher::new() – recording is opt-in and costs nothing in production.

Assert on the event, not on the listener’s side effect, when what you care about is that the event fired. Assert on the listener when what you care about is what it did.

Mail

Mailer::capture_ok() accepts every message and keeps it; Mailer::capture_error() rejects every message. Both are constructors, so a test builds one directly instead of pointing SMTP at a local catcher.

use arcature::mail::Mailer;

let mailer = Mailer::capture_ok();
// ... run the code under test ...
let sent = mailer.captured().await.expect("capturing mailer");
assert_eq!(sent.len(), 1);

captured() returns Option<Vec<(Envelope, String)>>None when the mailer is not a capturing one, so a test that accidentally runs against real SMTP fails on the expect rather than passing vacuously. The String is the serialised message; the Envelope carries the actual sender and recipients, which is what you want to assert on, since the envelope and the To: header can legitimately differ.

capture_error() is the one that finds the bugs: it proves the calling code handles a send failure instead of unwrapping it.

Jobs

A job handler is an ordinary async function over a deserialised payload. Test it by calling it. That covers the interesting part – the business logic and the JobError::Retryable / JobError::Permanent decision – without a database.

Testing the queue itself needs PostgreSQL, because the claim protocol is FOR UPDATE SKIP LOCKED and there is nothing to emulate it with. The framework’s own suite takes a DATABASE_URL for exactly this reason; see Deployment for the CI service definition.

Validation

Validated<T> and its siblings are extractors, so they are tested through a request. A 422 with an errors extension on the problem document is the success case for a validation test – assert on the field names in that extension, not on the message strings, which are not a stable interface.

Databases in tests

There is no per-test transaction rollback helper and no test database provisioner. A test that needs a database connects to one named by an environment variable and is responsible for its own cleanup. This is less convenient than the Laravel equivalent and is a known gap.

The test-kit feature

into_router plus oneshot covers the router-level pipeline and needs no framework support at all, which is why the chapter leads with it. When a test needs the whole application – subsystem startup, state construction, the lot – arcature::test_kit boots one in process and drives it as a tower::Service, so there is no socket, no port and no teardown race.

Enable it under [dev-dependencies]:

[dev-dependencies]
arcature = { version = "0.1", features = ["test-kit"] }

The feature belongs there and nowhere else: shipping a test harness inside a production binary is the mistake the feature split exists to prevent.

What it holds: TestApp (the in-process driver) and TestServer (a real socket, for the few things a tower::Service call cannot exercise, such as a WebSocket upgrade); TestRequest and TestResponse with the assertions; session seeding behind the auth feature, so acting_as has something to act as; a two-condition database gate with transaction-per-test and assert_database_has behind the database feature; and recorder fakes for events, jobs and mail, wired into the seams those subsystems already expose rather than being parallel copies of them. #[arcature::test(app = ...)] binds a fresh TestApp to the test function’s parameter.

It registers nothing globally – no inventory, no thread-local, no ambient application. A test names the thing it is testing and holds it in a value.