Skip to content

End-to-end tests

TestApp boots the real AppModule against an EphemeralDatabase — the route table, the DI graph, the access-graph check, authn/authz, and the data path all run.

An end-to-end test boots the real top-level module in process, against a throwaway Postgres database, and drives the HTTP surface through the same poem runtime production uses. Every app ships one, at apps/<app>/tests/e2e/main.rs — the same tests/<suite>/main.rs directory form as every test binary in the codebase (see Integration tests for the layout rule). The happy path covered here pairs with negative-path tests — auth refused, cross-tenant denied, rollback verified — for the bugs a 2xx round-trip never trips.

apps/api/tests/e2e/harness.rs (abridged)
use api::ApiModule;
use nest_rs::authn::JwtConfig;
use nest_rs::testing::{EphemeralDatabase, TestApp};
use nest_rs::http::poem::http::header;
use serde_json::json;
pub(crate) async fn boot() -> (EphemeralDatabase, TestApp) {
let db = EphemeralDatabase::create::<migrations::Migrator>()
.await
.expect("create + migrate a throwaway database");
let app = TestApp::builder()
.module::<ApiModule>()
.provide_arc(db.connection())
.provide(JwtConfig {
public_key: Some(DEV_PUBLIC_KEY.into()),
..Default::default()
})
.build()
.await
.expect("ApiModule boots against the throwaway database");
(db, app)
}

Each test module reaches for that boot() and the token helpers beside it:

apps/api/tests/e2e/users.rs (from the demo, abridged)
use super::harness::*;
#[tokio::test]
async fn create_then_list_users() {
let (_db, app) = boot().await;
let bearer = format!("Bearer {}", login().await);
let created = app
.http()
.post("/users")
.header(header::AUTHORIZATION, &bearer)
.body_json(&json!({ "name": "Ada", "email": "ada@acme.test" }))
.send()
.await;
created.assert_status_is_ok();
let listed = app
.http()
.get("/users")
.header(header::AUTHORIZATION, &bearer)
.send()
.await;
listed.assert_status_is_ok();
}

TestApp::builder().module::<ApiModule>() runs the same four-phase boot as main, including the access-graph check. A wiring regression fails this test, in this binary.

A small suite keeps its tests directly in main.rs. When it gets unwieldy, add sibling modules — the path never changes. Cargo auto-detects tests/e2e/main.rs as a single integration-test binary named e2e, and — unlike top-level tests/*.rs — it does not compile the sibling files under tests/e2e/ as their own binaries. So the whole subtree links into one e2e binary that the nextest binary(e2e) gate still catches.

apps/api/
└── tests/
└── e2e/
├── main.rs ← the crate root: mod harness; mod users; …
├── harness.rs ← boot(), token_for(), fixtures
├── users.rs
├── orgs.rs
├── posts.rs
└── graphql.rs

tests/e2e/main.rs is a flat index:

apps/api/tests/e2e/main.rs (from the demo)
mod harness;
mod audio;
mod graphql;
mod health;
mod http;
mod openapi;
mod orgs;
mod posts;
mod users;

Each module pulls shared setup from the harness with one line — use super::harness::*;, as in the test above.

Because main.rs is the crate root, mod users; resolves to a sibling tests/e2e/users.rs in the same directory — and harness.rs needs no mod.rs trick, since nothing under tests/e2e/ is auto-discovered as a binary.

Why one binary instead of tests/e2e_users.rs, tests/e2e_orgs.rs:

  • The four-phase boot is the expensive part — sharing boot() through harness keeps it written once.
  • Cargo links the app crate once per binary; one binary means one link step instead of one per concern file.
  • nestrs run test e2e runs the whole suite from the workspace root.

EphemeralDatabase::create::<Migrator>() creates a uniquely-named Postgres database, runs migrations, and drops it on the guard’s Drop — even on panic, even after a crashed previous run (it reaps nest_rs_e2e_* databases older than five minutes on the next call). The admin URL comes from NESTRS_SEAORM__URL.

provide_arc(db.connection()) seeds the real connection into the container, short-circuiting SeaOrmDatabaseModule’s for_root factory. The rest of the app builds normally.

apps/api/tests/e2e/users.rs
let db = EphemeralDatabase::create::<migrations::Migrator>().await?;
let app = TestApp::builder()
.module::<ApiModule>()
.provide_arc(db.connection())
.build()
.await?;

app.http() returns poem::test::TestClient — typed requests, typed responses, no socket. GraphQL (POST /graphql), the OpenAPI document (GET /api-json), and MCP all ride the same client because they self-mount as HTTP endpoints.

It drives the transport your app’s HttpModule::for_root(cfg) describes, not a bare one — so a global prefix, a versioning strategy, a body cap or a request timeout you pin on the module is what the suite exercises. Pin the HttpConfig on the module to test one; TestAppBuilder::http(transport) is for a transport the app does not declare at all.

apps/api/tests/e2e/openapi.rs
let resp = app.http().get("/api-json").send().await;
resp.assert_status_is_ok();
let paths = resp.json().await.value().object().get("paths").object();
assert!(paths.get_opt("/users").is_some());

Three builders swap a provider after the four-phase build. The override applies before any consumer resolves the type, so controllers, resolvers, and guards pick it up.

BuilderTakesUse when
override_value::<T>(value)T (owned)The test does not need to hold the fake — let the container own it
override_dyn::<T>(Arc<T>)Arc<dyn Trait>The provider is registered behind a pub trait
override_arc::<T>(Arc<T>)Arc<T> (concrete)The test still holds the fake to read its state
apps/api/tests/e2e/weather.rs
struct StubWeather;
#[async_trait]
impl WeatherProvider for StubWeather {
async fn current(&self, _lat: f64, _lon: f64) -> Result<Report> {
Ok(Report {
temperature_c: 20.0,
wind_speed_kmh: 0.0,
wind_direction_deg: 0.0,
weather_code: 0,
observed_at: "2026-06-03T10:00:00Z".into(),
})
}
}
let app = TestApp::builder()
.module::<AppModule>()
.override_dyn::<dyn WeatherProvider>(Arc::new(StubWeather))
.build()
.await?;

override_arc is the right shape when the test needs to observe what the fake recorded — wrap it in Arc once, hand the same handle to the container and the test, then assert against the shared state after the request:

apps/api/tests/e2e/users.rs
use std::sync::Arc;
use nest_rs::core::injectable;
use parking_lot::Mutex;
#[injectable]
#[derive(Default)]
struct RecordingMailer {
sent: Mutex<Vec<String>>,
}
impl RecordingMailer {
fn sent(&self) -> Vec<String> { self.sent.lock().clone() }
}
impl Mailer for RecordingMailer {
fn send(&self, to: &str) -> Result<(), MailError> {
self.sent.lock().push(to.to_owned());
Ok(())
}
}
#[tokio::test]
async fn signup_emails_the_new_user() {
let mailer = Arc::new(RecordingMailer::default());
let app = TestApp::builder()
.module::<UsersModule>()
.override_arc::<RecordingMailer>(Arc::clone(&mailer))
.build()
.await
.unwrap();
app.http()
.post("/users")
.body_json(&json!({ "email": "ada@example.com" }))
.send()
.await
.assert_status_is_ok();
assert_eq!(mailer.sent(), vec!["ada@example.com".to_string()]);
}

override_arc and override_value both replace a concrete provider. The difference is who keeps the handle: override_value consumes its argument (you can’t read it back later), override_arc takes a pre-cloned Arc so the test and the container share the same instance.

External-IO services — HTTP clients to third-party APIs, queue producers reaching an outside system — are the right override target. The database is not. A test that mocks Repo to make a service compile is asserting on a fiction; use EphemeralDatabase instead.

build_headless() skips the HTTP transport for queue workers, schedulers, or any binary without an HTTP surface. The four-phase build still runs, so the access-graph check still fires.

apps/api/tests/e2e/worker.rs
let app = TestApp::builder()
.module::<RedisWorkerModule>()
.provide_arc(db.connection())
.build_headless()
.await?;

Drive non-HTTP transports through HeadlessApp::spawn_transport.

with_test_telemetry() calls OpenTelemetry::init_for_tests — a console-only init honouring NESTRS_LOG then RUST_LOG (default warn for noise control). It is idempotent; the first test to run wins, the rest no-op. Required when the module tree imports OpenTelemetryModule (the boot guard panics otherwise). Gated by the opentelemetry feature on nest-rs-testing.

A test whose module tree does not import OpenTelemetryModule does not need it.

TestApp’s client speaks HTTP, and a subscription does not. graphql_socket drives the graphql-transport-ws protocol against the app’s composed schema — connection_init → connection_ack → subscribe → next — with no socket bound. (graphql-ws is the legacy subprotocol identifier, which the mount also negotiates and this driver does not speak.)

apps/api/tests/e2e/subscription.rs
let mut socket = app.graphql_socket().data(ability).open();
socket.connect().await;
socket.subscribe("1", "subscription { postPublished { id title } }");
let item = socket.next_item("1").await.expect("an item");
assert_eq!(item["data"]["postPublished"]["title"], "Launch");

data(..) carries what the operation guard would install on a real upgrade — the caller’s Ability. What this does not exercise is the upgrade itself: the guard that authenticates it, the socket-lifetime ceiling that bounds it, and max_connection. Those need a real connection, so a suite that must cover them binds a transport (build_headless + spawn_transport) and connects a WebSocket client to it.

E2E is not enough on its own. After the test goes green, run the binary and curl the affected endpoint:

Terminal window
$ nestrs run dev api &
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/users
$ kill %1

Stop the server when you’re done — kill %1, or the port stays bound for the next run.