Skip to content

End-to-end tests

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

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

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.

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_provider::<T>(Arc<T>)Arc<T> (concrete)The test still holds the fake to read its state
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_provider 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:

use std::sync::Arc;
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_provider::<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_provider 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_provider 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.

let app = TestApp::builder()
.module::<WorkerModule>()
.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.

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 donekill %1, or the port stays bound for the next run.

Built by YV17labs