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.
A round-trip e2e test
Section titled “A round-trip e2e test”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:
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.
Splitting a large suite
Section titled “Splitting a large suite”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.rstests/e2e/main.rs is a flat index:
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()throughharnesskeeps 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::<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.
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 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.
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 %1Stop the server when you’re done — kill %1, or the port stays
bound for the next run.
Going further
Section titled “Going further”- Negative-path tests — the failure modes a 2xx round-trip never trips.
- Policy tests —
Ability::maskandcondition_foras pure units. - Publish demo — the exemplar apps these tests boot.
Built by YV17labs