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.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).

  • Directoryapps/blog/
    • Directorytests/
      • e2e.rs

Add the dev-dependencies the test needs:

apps/blog/Cargo.toml
[dev-dependencies]
nest-rs-testing = { workspace = true, features = ["opentelemetry", "orm"] }
migrations = { workspace = true }
poem = { workspace = true, features = ["test"] }
serde_json.workspace = true

TestApp::builder() runs the same four-phase boot as main, including the access-graph check. The harness seeds the connection so the module never reaches a real network — no JWT seeding, because blog has no auth layer.

apps/blog/tests/e2e.rs
use nest_rs_testing::{EphemeralDatabase, TestApp};
use poem::http::StatusCode;
use serde_json::json;
use blog::BlogModule;
async fn boot() -> (EphemeralDatabase, TestApp) {
let db = EphemeralDatabase::create::<migrations::Migrator>()
.await
.expect("create + migrate a throwaway database");
let app = TestApp::builder()
.module::<BlogModule>()
.with_test_telemetry()
.provide_arc(db.connection())
.build()
.await
.expect("BlogModule boots against the throwaway database");
(db, app)
}

You need a blog library target to import BlogModule from the test binary. Add the lib alongside main.rs:

apps/blog/src/lib.rs
mod module;
pub use module::BlogModule;
apps/blog/Cargo.toml
[lib]
path = "src/lib.rs"
[[bin]]
name = "blog"
path = "src/main.rs"

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.rs
#[tokio::test]
async fn posts_round_trip() {
let (_db, app) = boot().await;
let created = app
.http()
.post("/posts")
.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}")).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 on page 5.

apps/blog/tests/e2e.rs
#[tokio::test]
async fn create_with_empty_title_returns_400() {
let (_db, app) = boot().await;
app.http()
.post("/posts")
.body_json(&json!({ "title": "", "body": "World" }))
.send()
.await
.assert_status(StatusCode::BAD_REQUEST);
}

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 page 6).

    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 2 tests across 1 binary
    PASS [ 1.4s] features::e2e posts_round_trip
    PASS [ 0.8s] features::e2e create_with_empty_title_returns_400
  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.rs.

  • A tests/e2e.rs booting the real BlogModule against a throwaway Postgres provisioned by testcontainers.
  • A happy-path test and a validation failure — 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.