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.rs.
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.
A round-trip e2e test
Section titled “A round-trip e2e test”use nest_rs_authn::JwtConfig;use nest_rs_testing::{EphemeralDatabase, TestApp};use api::ApiModule;use poem::http::header;use serde_json::json;
async fn boot() -> (EphemeralDatabase, TestApp) { let db = EphemeralDatabase::create::<migrations::Migrator>() .await .expect("create + migrate a throwaway database"); let app = TestApp::builder() .module::<ApiModule>() .with_test_telemetry() .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)}
#[tokio::test]async fn create_then_list_users() { let (_db, app) = boot().await; let bearer = format!("Bearer {}", admin_token().await);
let created = app .http() .post("/users") .header(header::AUTHORIZATION, &bearer) .body_json(&json!({ "name": "Bob", "email": "bob@example.com" })) .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.
Splitting a large suite
Section titled “Splitting a large suite”Cargo compiles apps/<app>/tests/e2e.rs as one binary and treats
subdirectories as modules. That seam grows the suite without going to
multiple binaries: keep one top-level e2e.rs, declare modules under
it, and share the boot() helper through tests/e2e/common/mod.rs.
apps/api/└── tests/ ├── e2e.rs └── e2e/ ├── common/ │ └── mod.rs ← boot(), admin_token(), fixtures ├── users.rs ├── orgs.rs ├── auth.rs └── graphql.rstests/e2e.rs becomes a flat index:
mod common;mod users;mod orgs;mod auth;mod graphql;Each module pulls shared setup from crate::common:
use crate::common::{admin_token, boot};use poem::http::header;use serde_json::json;
#[tokio::test]async fn create_then_list_users() { let (_db, app) = boot().await; let bearer = format!("Bearer {}", admin_token().await);
let created = app .http() .post("/users") .header(header::AUTHORIZATION, &bearer) .body_json(&json!({ "name": "Bob", "email": "bob@example.com" })) .send() .await; created.assert_status_is_ok();}Rust’s module rule: mod common; in tests/e2e.rs resolves to
tests/e2e/common/mod.rs because the directory takes the entry file’s
stem. The mod.rs form on common matters — it’s the
official Rust idiom
that prevents Cargo from compiling it as its own binary.
Why one binary instead of tests/e2e_users.rs, tests/e2e_orgs.rs:
- The four-phase boot is the expensive part — sharing
boot()throughcommonkeeps 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 e2eruns the whole suite from the workspace root.
Ephemeral Postgres
Section titled “Ephemeral Postgres”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?;Driving the HTTP surface
Section titled “Driving the HTTP surface”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());Provider overrides
Section titled “Provider overrides”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.
| Builder | Takes | Use 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::<AssistantModule>() .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.
Headless apps
Section titled “Headless apps”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.
Test telemetry
Section titled “Test telemetry”with_test_telemetry() calls OpenTelemetry::init_for_tests — a
console-only init honouring 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.
What ends an HTTP/GraphQL change
Section titled “What ends an HTTP/GraphQL change”E2E is not enough on its own. After the test goes green, run the binary
and curl the affected endpoint:
$ nestrs run dev api &$ curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/users$ kill %1Kill the server before returning control. If you cannot run the binary, say so explicitly rather than claiming green.