Skip to content
ExponaBLOG
NESTJS · DAY 052 / 100

E2E tests with Supertest: NestJS lessons from production

Test the full HTTP stack like a client would.

Expona Admin9 min read

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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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() and afterEach() hooks to avoid resource leaks and hanging connections.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles