E2E tests with Supertest
Most unit tests mock the database and HTTP layer, so you never see how your actual system behaves under real conditions. E2E tests with Supertest change that: they boot your real NestJS application, hit actual endpoints, and verify the full stack works as a client would experience it. In this article, I'll walk you through testing your production flows end-to-end without opening ports or managing external test servers.
Why E2E tests matter in production systems
I've shipped enough trading platforms and e-commerce systems to know that unit test coverage isn't enough. A function might pass its unit tests perfectly, but when it talks to a real database, caches, and multiple services, things break. E2E tests with Supertest let you catch those integration failures early—before they hit staging or production.
The beauty of Supertest is simplicity: it's just an HTTP assertion library that doesn't require you to spin up a real server on a port. It directly injects requests into your NestJS application and captures responses. For critical flows like user authentication, payment processing, and permission checks, this is non-negotiable.
Boot the real app—creating the Nest application from AppModule inside the test
Your E2E tests start by instantiating your actual application. This isn't a mock or a stub; it's the real AppModule with real providers, real guards, and real middleware. Here's how:
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Leads API (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('should be defined', () => {
expect(app).toBeDefined();
});
});
The Test.createTestingModule call loads your entire application graph. Every controller, service, guard, and middleware gets instantiated. If your AppModule connects to a real PostgreSQL database, it will connect. If you have Kafka listeners, they'll spin up. This is intentional—you're testing against production-like conditions.
For a CRM or SaaS platform, this means your authentication guards, role-based access control, and database triggers all run exactly as they would in production. That's the whole point.
Hit real endpoints—Supertest sends requests without opening ports
Once your app is booted, Supertest's request() function lets you make HTTP calls directly to the application instance. No port binding, no network overhead—just request injection:
describe('POST /leads', () => {
it('creates a lead with valid data', () => {
return request(app.getHttpServer())
.post('/leads')
.send({ name: 'Asha', email: 'asha@example.com' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.name).toBe('Asha');
expect(res.body.email).toBe('asha@example.com');
});
});
it('returns 400 for missing email', () => {
return request(app.getHttpServer())
.post('/leads')
.send({ name: 'Asha' })
.expect(400);
});
});
Notice the chain: request(app.getHttpServer()).post('/leads').send(...).expect(...). Each method returns a promise, so you can either return it directly or use async/await. Supertest validates the status code, response body, headers, and whatever custom assertions you add.
In a ride-hailing app I built, we tested booking creation with real location data, payment gateway mocking, and driver assignment logic. The E2E test caught a race condition in driver matching that unit tests never would have found because they didn't exercise the full workflow.
You can also test error paths, authentication, and redirects:
describe('Authentication', () => {
it('denies unauthenticated access to protected endpoints', () => {
return request(app.getHttpServer())
.get('/profile')
.expect(401);
});
it('allows authenticated users with valid JWT', () => {
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
return request(app.getHttpServer())
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
});
Setting headers like Authorization is as simple as chaining .set(key, value). For file uploads, use .attach(fieldName, filePath). Supertest handles all standard HTTP patterns.
Real database—run against Postgres in Docker, reset between suites
The real test begins when you connect to an actual database. This is where integration issues surface. I recommend spinning up a Postgres container before running your E2E suite and tearing it down after.
In your test environment configuration, override the database connection to point to a test instance:
// test/setup.ts
import { spawn } from 'child_process';
import * as waitPort from 'wait-port';
let container: any;
export async function startTestDatabase() {
return new Promise((resolve, reject) => {
// Start a Postgres container on port 5433 for testing
container = spawn('docker', [
'run',
'--rm',
'-p', '5433:5432',
'-e', 'POSTGRES_PASSWORD=test',
'-e', 'POSTGRES_DB=test_db',
'postgres:15-alpine',
]);
// Wait for Postgres to be ready
waitPort({ host: 'localhost', port: 5433, timeout: 30000 })
.then(() => resolve(container))
.catch(reject);
});
}
export async function stopTestDatabase() {
return new Promise((resolve) => {
if (container) {
container.kill();
container.on('exit', resolve);
} else {
resolve(null);
}
});
}
Then in your test file, reset the database between test suites to ensure isolation:
import { getConnection } from 'typeorm';
describe('Leads API (e2e)', () => {
// ... app setup ...
beforeEach(async () => {
// Clear all tables before each test
const connection = getConnection();
const entities = connection.entityMetadatas;
for (const entity of entities) {
const repository = connection.getRepository(entity.name);
await repository.clear();
}
});
it('creates a lead and retrieves it', async () => {
// Create
const createRes = await request(app.getHttpServer())
.post('/leads')
.send({ name: 'Raj', email: 'raj@example.com' })
.expect(201);
const leadId = createRes.body.id;
// Retrieve—verify it was persisted
await request(app.getHttpServer())
.get(`/leads/${leadId}`)
.expect(200)
.expect((res) => {
expect(res.body.name).toBe('Raj');
});
});
});
This approach is solid for testing stateful systems like e-commerce platforms or SaaS applications where you need transaction consistency and multi-step workflows. Every test starts with a clean slate, so tests don't interfere with each other.
For systems using TypeORM, you can also use transactions and rollback after each test if performance is a concern:
let queryRunner: QueryRunner;
beforeEach(async () => {
const connection = getConnection();
queryRunner = connection.createQueryRunner();
await queryRunner.startTransaction();
});
afterEach(async () => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
});
Cover critical flows—auth, payments, permissions; not every endpoint
You don't need to E2E test every single endpoint. That's impractical and slow. Instead, focus on critical business flows that would cause real pain if broken: user signup and login, payment processing, permission checks, and role-based access control.
For a travel booking platform I worked on, we had E2E tests for:
- User registration and email verification
- Login with JWT token generation
- Booking creation with payment processing
- Admin approval workflow with permission checks
Here's an example of a multi-step authentication flow:
describe('User Auth Flow', () => {
it('registers, logs in, and accesses protected resource', async () => {
// Step 1: Register
const signupRes = await request(app.getHttpServer())
.post('/auth/signup')
.send({
email: 'newuser@example.com',
password: 'SecurePass123!',
name: 'New User',
})
.expect(201);
expect(signupRes.body).toHaveProperty('id');
const userId = signupRes.body.id;
// Step 2: Login
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'newuser@example.com',
password: 'SecurePass123!',
})
.expect(200);
expect(loginRes.body).toHaveProperty('access_token');
const token = loginRes.body.access_token;
// Step 3: Access protected endpoint with token
await request(app.getHttpServer())
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(userId);
expect(res.body.email).toBe('newuser@example.com');
});
});
it('denies login with wrong password', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'newuser@example.com',
password: 'WrongPassword',
})
.expect(401);
});
});
And here's a permission-based flow for a CRM where only admins can delete leads:
describe('Lead Deletion Permissions', () => {
let leadId: string;
let adminToken: string;
let userToken: string;
beforeEach(async () => {
// Create a lead
const leadRes = await request(app.getHttpServer())
.post('/leads')
.send({ name: 'John', email: 'john@example.com' })
.expect(201);
leadId = leadRes.body.id;
// Get admin token
adminToken = await getTokenForRole('admin');
// Get regular user token
userToken = await getTokenForRole('user');
});
it('allows admin to delete a lead', () => {
return request(app.getHttpServer())
.delete(`/leads/${leadId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(204);
});
it('denies regular user from deleting a lead', () => {
return request(app.getHttpServer())
.delete(`/leads/${leadId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
});
This strategy keeps your E2E suite fast and maintainable. You're testing the happy path, error cases, and permission boundaries—not every single getter and setter.
Common mistakes
Not cleaning up the database between tests. State leaks cause flaky tests. Always reset or roll back transactions between test cases.
Testing implementation details instead of behavior. Don't assert on database record IDs or internal field names that users don't care about. Test what the API returns and whether business logic worked.
Creating too many E2E tests. E2E tests are slow. If you're testing every CRUD operation end-to-end, you're spending more time on tests than features. Use unit tests for business logic, E2E for critical flows.
Hardcoding magic strings and IDs. Use factory functions or seed data to create consistent test data. This makes tests readable and maintainable as your schema evolves.
Not mocking external services. If your app calls a payment gateway or email service, mock those in tests. Use libraries like jest.mock() or NestJS's testing utilities to replace real implementations with stubs.
Ignoring async/await patterns. Supertest returns promises. Make sure you're returning or awaiting them properly, or tests will pass before assertions even run.
Key takeaways
- E2E tests with Supertest boot your real NestJS application and test the full HTTP stack without opening a port.
- Use
Test.createTestingModule({ imports: [AppModule] })to instantiate your actual application with all guards, middleware, and providers active. - Chain Supertest methods like
.post(),.send(),.set(), and.expect()to assert on status codes, response bodies, and headers. - Connect to a real test database (Postgres in Docker is a solid choice) and reset state between tests to prevent flakiness.
- Focus E2E tests on critical business flows: authentication, payments, permissions, and multi-step workflows. Don't test every endpoint.
- Clean up properly in
afterAll()andafterEach()hooks to avoid resource leaks and hanging connections.






Comments
Loading comments…