Skip to content

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.

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:

apps/blog/Cargo.toml
[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 = true

features = ["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"].

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.

apps/blog/tests/e2e/main.rs
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:

apps/blog/src/lib.rs
mod module;
pub use module::BlogModule;

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.

apps/blog/tests/e2e/main.rs
#[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.

A happy-path test is half a test. Add one negative path — the same 400 you verified by hand in Validate inputs.

apps/blog/tests/e2e/main.rs
#[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);
}

nestrs run test e2e is the recipe; it routes to nextest with the binary filter for the e2e file.

  1. Bring Postgres up (already done in Persist through Postgres).

    Terminal window
    $ nestrs run db up
    postgres ready on 127.0.0.1:5432
  2. Run the e2e suite.

    Terminal window
    $ nestrs run test e2e
    ────────────
    Starting 3 tests across 1 binary
    PASS [ 1.4s] blog::e2e posts_round_trip
    PASS [ 0.8s] blog::e2e create_with_empty_title_returns_400
    PASS [ 0.7s] blog::e2e create_without_a_token_returns_401
  3. 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.

  • A tests/e2e/main.rs booting the real BlogModule against 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 e2e recipe running them green.

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.

Built by YV17labs