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.
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.