E2E testing with supertest
E2E tests boot the whole app (createTestingModule(AppModule), createNestApplication, app.init), then drive it with supertest against the real pipeline, a real test DB, and a real JWT or an overridden guard. Always app.close().
Your UsersService.create unit test is green. It mocks the repository, asserts the DTO maps cleanly, and runs in two milliseconds. Then prod returns a 500 on the very first POST /users: the global ValidationPipe rejects a field your mocked DTO never saw, the JwtAuthGuard you forgot to whitelist blocks the route, and the unique constraint on email — invisible to a mocked repo — throws a raw Postgres error your filter doesn’t catch. None of these bugs live in UsersService. They live in the wiring: the pipe, the guard, the filter, the real schema. A unit test mocks all of that away, so it can never see them. The fix is a test that boots the actual application and talks to it over HTTP, exactly like a client would.
Boot the whole app, then talk HTTP to it
In ten minutes you’ll have a working e2e setup that catches the wiring bugs a unit test can never see — the pipe that rejects a field, the guard that silently blocks a route.
A unit test instantiates one class with fakes around it. An e2e test does the opposite: it compiles the entire module graph from AppModule, builds a real Nest application, and exercises it through its HTTP listener. Two methods you skip in unit tests become central here. compile() only builds the provider graph — the HttpAdapterHost has no server yet. createNestApplication() is what instantiates the full runtime with an HTTP adapter; app.init() then runs every lifecycle hook (onModuleInit, onApplicationBootstrap) and registers your global pipes, guards, interceptors, and filters.
import * as request from 'supertest';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule], // the WHOLE app, not one provider
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // mirror main.ts
await app.init(); // runs lifecycle hooks + wires globals
});
it('POST /users then GET /users/:id', async () => {
const created = await request(app.getHttpServer())
.post('/users')
.send({ email: 'ada@example.com', name: 'Ada' })
.expect(201);
await request(app.getHttpServer())
.get(`/users/${created.body.id}`)
.expect(200)
.expect((res) => {
if (res.body.email !== 'ada@example.com') throw new Error('email mismatch');
});
});
afterAll(async () => {
await app.close(); // release DB handles + open sockets
});
});request(app.getHttpServer()) hands supertest the underlying Node HTTP listener. Supertest binds it to an ephemeral port, sends a real request, and the response walks the real pipeline. That single .post('/users') runs your global ValidationPipe, every guard on the route, the controller, the service, the repository against a real DB, and your exception filter on the way out — which is precisely why this test catches the wiring bugs the unit test mocked away.
▸Why this works
Why does e2e catch what unit tests can’t? A unit test of UsersService replaces the repository, the pipe, and the guard with fakes — so by construction it tests the service in isolation from the very things that broke in production. The 500s in the hook were a ValidationPipe rejecting a field, a JwtAuthGuard blocking a route, and a unique-constraint violation. All three are wiring, not service logic. Supertest sends a request through the assembled application, so the pipe really validates, the guard really runs, and the real schema really enforces its constraints. You trade speed (milliseconds become hundreds of ms) for coverage of the integration seams where most production incidents actually live.
Point the app at a real, disposable test database
The DB is what makes e2e true. Mock the repository and you have lied about the one layer most likely to break — constraints, transactions, the actual SQL. So an honest e2e suite runs against a real but ephemeral database: a dockerized Postgres (often via Testcontainers, which spins up a throwaway container per run), or a lightweight engine like sqlite. You run migrations to build the schema, seed any fixtures, and — critically — reset state between tests so they stay independent.
You override the production data source so the app talks to the test DB instead. With TypeORM, swap the DataSource provider by its token:
import { getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
const testDataSource = new DataSource({
type: 'postgres',
url: process.env.TEST_DATABASE_URL, // a throwaway container, not prod
entities: [User],
synchronize: false, // run real migrations instead
});
beforeAll(async () => {
await testDataSource.initialize();
await testDataSource.runMigrations();
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(getDataSourceToken()) // replace prod DataSource...
.useValue(testDataSource) // ...with the test one
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
beforeEach(async () => {
// isolation: wipe rows between tests so order never matters
await testDataSource.query('TRUNCATE TABLE "user" RESTART IDENTITY CASCADE');
});TRUNCATE between tests is the cheapest path to isolation: each it starts from a known-empty table, so a test that creates a user can’t poison the next one. The alternative — wrapping each test in a transaction and rolling back — is faster but breaks when your code opens its own transactions. Env-switched config (a .env.test that points DATABASE_URL at the container) is a simpler alternative to overrideProvider when the whole app reads one connection string.
Auth in tests: a real token, or bypass the guard
If your routes sit behind a JwtAuthGuard, every e2e request needs to satisfy it — and you have two honest options. Option one: get a real token. Hit /auth/login (or sign one with the test secret) and attach it, so the guard runs for real:
let token: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'ada@example.com', password: 'pw' });
token = res.body.accessToken;
});
it('GET /me with a real JWT', () =>
request(app.getHttpServer())
.get('/me')
.set('Authorization', `Bearer ${token}`) // guard runs, validates the token
.expect(200));Option two: override the guard when auth is not what you’re testing, so you don’t pay the login dance on every spec:
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true }) // every request is "authenticated"
.compile();The tradeoff is realism versus speed. A real token exercises the entire auth path — token signing, the guard, the strategy, req.user population — so it catches auth wiring bugs; it’s slower and couples your test to the login flow. Overriding the guard is fast and focused, but it means this suite proves nothing about authentication, so you still need at least one spec that uses a real token end to end.
Lifecycle hygiene: close the app, isolate the state
The single most common e2e failure isn’t an assertion — it’s Jest hanging at the end with “a worker process has failed to exit gracefully… open handles”. That’s an app you booted but never closed: the DB pool and the HTTP socket are still open. afterAll(() => app.close()) runs the shutdown hooks and releases them. Pair it with resetting DB state per test, and never let one test depend on another’s leftovers.
| Pitfall | Symptom | Fix |
|---|---|---|
| App never closed | Jest hangs; “open handles” / “failed to exit gracefully” | await app.close() in afterAll |
| Leaked connections | ”too many clients”; pool exhaustion across suites | Close the DataSource too; one app per suite, not per test |
| Shared mutable state | Tests pass alone, fail together | TRUNCATE / rollback in beforeEach; no cross-test fixtures |
| Order-dependent tests | Green in file order, red when randomized/sharded | Each test seeds its own data; assert nothing about prior rows |
Your API has a unique constraint on email and a money transfer wrapped in a transaction. You want the e2e suite to catch real SQL and constraint bugs, not just confirm the service's happy path. What backs the e2e database?
Why does request(app.getHttpServer()).post('/users') catch a global ValidationPipe bug that a unit test of UsersService.create cannot?
Your e2e suite passes when each file runs alone but fails when Jest runs them together, and CI sometimes warns about open handles. What are the two root causes?
- 01Walk the e2e setup end to end: which methods boot the app, what does supertest drive, and why does this catch bugs a unit test can't?
- 02How do you give an e2e suite a real test DB and handle auth, and what lifecycle hygiene keeps it from leaking or going flaky?
An e2e test is the inverse of a unit test: instead of one class wrapped in fakes, it boots the entire application and drives it over HTTP. You compile the whole graph with Test.createTestingModule({ imports: [AppModule] }).compile(), then call createNestApplication() to build the full runtime with an HTTP adapter — compile() alone leaves no server — and await app.init() to run lifecycle hooks and register your global pipes, guards, interceptors, and filters. request(app.getHttpServer()) hands supertest the real HTTP listener, so a .post(‘/users’).send(dto).expect(201) walks the genuine pipeline: the ValidationPipe validates, every guard runs, the handler hits a real repository, and the exception filter shapes any error. That’s why e2e catches wiring bugs — a rejected field, a blocking guard, a unique-constraint violation — that a mocked unit test can never see. Back it with a real but disposable database: a dockerized Postgres (Testcontainers) or sqlite, with real migrations, seeded fixtures, and a TRUNCATE between tests for isolation, overriding the production DataSource via .overrideProvider(getDataSourceToken()).useValue(testDataSource) or an env-switched connection string. For auth, either fetch a real JWT and set the Authorization header (realistic, exercises the whole auth path) or .overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }) to bypass it (fast, but proves nothing about authentication). Finally, keep the suite clean: afterAll(() => app.close()) releases the DB pool and socket so Jest doesn’t hang on open handles, reset DB state per test, and never let one test depend on another. Now when you see “open handles” in CI or a suite that passes alone but fails together, you’ll know exactly where to look: an unclosed app, a shared DB row, or a missing TRUNCATE in beforeEach.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.