Test it end to end
You ship one e2e test that boots the real BlogModule
against a throwaway Postgres, drives it through the same poem runtime
production uses, and asserts on the round-trip you’ve been verifying
by hand. By the end of this page, nestrs run test e2e runs it green, a
wiring regression in any of the previous six steps fails this test,
and the feature is locked down.
One e2e binary per app
Section titled “One e2e binary per app”Every app ships one tests/e2e/main.rs. Cargo compiles it as a separate
binary; nextest gates it through a binary filter, not #[ignore].
The crate driving this page is
nest-rs-testing —
it provides TestApp (the in-process boot harness) and
EphemeralDatabase (a throwaway Postgres provisioned through
testcontainers). EphemeralDatabase
needs a reachable Docker daemon — it starts and drops a real Postgres
container per suite.
Directoryapps/blog/
Directorytests/
Directorye2e/
- main.rs
Add the dev-dependencies the test needs:
[dev-dependencies]nest-rs-testing = { workspace = true, features = ["orm"] }nest-rs-authn = { workspace = true }migrations = { workspace = true }poem = { workspace = true, features = ["test"] }serde_json.workspace = truefeatures = ["orm"] is what brings EphemeralDatabase in. There is no
telemetry feature to enable: blog imports no OpenTelemetryModule, so
TestApp needs no with_test_telemetry() — that builder method exists only to
satisfy that module’s boot guard, and it lives behind
features = ["opentelemetry"].
The boot harness
Section titled “The boot harness”TestApp::builder() runs the same four-phase boot as main,
including the access-graph check. The harness seeds the connection, and
because the controller is guarded, it also mints the bearer every request
carries — the same JwtService the app verifies with.
use nest_rs_authn::{JwtOptions, JwtService};use nest_rs_testing::{EphemeralDatabase, TestApp};use poem::http::StatusCode;use serde_json::json;
use blog::BlogModule;use features::identity::{Claims, Role};
const SECRET: &str = "dev-only-insecure-secret-change-me-32b";
fn bearer() -> String { let jwt = JwtService::new(JwtOptions::new(SECRET)).expect("the dev secret is long enough"); let token = jwt .sign(&Claims { sub: None, roles: vec![Role::User], exp: jwt.expiry() }) .expect("sign the test token"); format!("Bearer {token}")}
async fn boot() -> (EphemeralDatabase, TestApp) { let db = EphemeralDatabase::create::<migrations::Migrator>() .await .expect("create + migrate a throwaway database"); let app = TestApp::builder() .module::<BlogModule>() .provide_arc(db.connection()) .build() .await .expect("BlogModule boots against the throwaway database"); (db, app)}The test binary imports BlogModule from a blog library target, which
the scaffold already wrote — Cargo auto-detects src/lib.rs and src/main.rs,
so the manifest needs no [lib] or [[bin]] section:
mod module;pub use module::BlogModule;The round-trip test
Section titled “The round-trip test”The shape of an e2e test: build the app, hit the route, read the response. Two requests, three assertions — enough to cover the entire wiring chain for a bare CRUD feature.
#[tokio::test]async fn posts_round_trip() { let (_db, app) = boot().await; let auth = bearer();
let created = app .http() .post("/posts") .header("authorization", &auth) .body_json(&json!({ "title": "Hello", "body": "World" })) .send() .await; created.assert_status_is_ok(); let body = created.json().await; let id = body.value().object().get("id").string().to_owned(); assert_eq!(body.value().object().get("title").string(), "Hello");
let got = app .http() .get(format!("/posts/{id}")) .header("authorization", &auth) .send() .await; got.assert_status_is_ok(); assert_eq!(got.json().await.value().object().get("body").string(), "World");}The assertions cover the entire chain: the POST walks validation, the
transaction interceptor, and the service; the GET /posts/:id walks
CrudService::find_by_id through Repo.
The validation failure
Section titled “The validation failure”A happy-path test is half a test. Add one negative path — the same
400 you verified by hand in Validate inputs.
#[tokio::test]async fn create_with_empty_title_returns_400() { let (_db, app) = boot().await; app.http() .post("/posts") .header("authorization", bearer()) .body_json(&json!({ "title": "", "body": "World" })) .send() .await .assert_status(StatusCode::BAD_REQUEST);}
#[tokio::test]async fn create_without_a_token_returns_401() { let (_db, app) = boot().await; app.http() .post("/posts") .body_json(&json!({ "title": "Hello", "body": "World" })) .send() .await .assert_status(StatusCode::UNAUTHORIZED);}Run it
Section titled “Run it”nestrs run test e2e is the recipe; it routes to nextest with the binary
filter for the e2e file.
-
Bring Postgres up (already done in Persist through Postgres).
Terminal window $ nestrs run db uppostgres ready on 127.0.0.1:5432 -
Run the e2e suite.
Terminal window $ nestrs run test e2e────────────Starting 3 tests across 1 binaryPASS [ 1.4s] blog::e2e posts_round_tripPASS [ 0.8s] blog::e2e create_with_empty_title_returns_400PASS [ 0.7s] blog::e2e create_without_a_token_returns_401 -
Run the rest of the workspace too — unit and integration tests don’t need a database.
Terminal window $ nestrs run test unit
nestrs run test unit and nestrs run test e2e are the two recipes you reach for day
to day; nestrs run test cov adds coverage and nestrs run test doc runs the
/// examples. None of them use #[ignore] — gating is the nextest binary filter
on tests/e2e/main.rs.
What you have now
Section titled “What you have now”- A
tests/e2e/main.rsbooting the realBlogModuleagainst a throwaway Postgres provisioned by testcontainers. - A happy path, a validation failure and an unauthenticated call — enough to catch a wiring regression in any of the previous six pages.
- A
nestrs run test e2erecipe running them green.
Where you are now
Section titled “Where you are now”You’ve built posts end to end: an entity declared once, a service
that gates every DB call, an HTTP controller mounted in blog, a
transactional Postgres, an e2e suite that locks the shape down. The
feature lives in crates/features/src/posts/; copying it is the way
to add the next bare feature.
When you need auth, GraphQL, org scoping, and the full negative-path
matrix, open api and
crates/features/src/users/
— same port-and-adapter layout, more layers on top.
Going further
Section titled “Going further”- Authenticate and authorize — the next step: add guards, ability, and masking on
api. - E2E tests — the reference page for
TestAppandEphemeralDatabase.
Built by YV17labs