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 test file per app
Section titled “One test file per app”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:
[dev-dependencies]nest-rs-testing = { workspace = true, features = ["opentelemetry", "orm"] }migrations = { workspace = true }poem = { workspace = true, features = ["test"] }serde_json.workspace = trueThe 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 so
the module never reaches a real network — no JWT seeding, because
blog has no auth layer.
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:
mod module;pub use module::BlogModule;[lib]path = "src/lib.rs"
[[bin]]name = "blog"path = "src/main.rs"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 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.
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 on page 5.
#[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);}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 page 6).
Terminal window $ nestrs run db uppostgres ready on 127.0.0.1:5432 -
Run the e2e suite.
Terminal window $ nestrs run test e2e────────────Starting 2 tests across 1 binaryPASS [ 1.4s] features::e2e posts_round_tripPASS [ 0.8s] features::e2e create_with_empty_title_returns_400 -
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.
What you have now
Section titled “What you have now”- A
tests/e2e.rsbooting the realBlogModuleagainst 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 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.