End-to-End Testing NestJS Apps with Supertest and TestingModule

End-to-End Testing NestJS Apps with Supertest and TestingModule

End-to-end tests for NestJS apps test the full HTTP stack — routing, guards, pipes, middleware, serialization — without deploying to a server. You boot your actual NestJS application inside the test process, send real HTTP requests against it, and assert on real HTTP responses. No mocks, no shortcuts.

This guide covers how to wire that up correctly, handle authentication, work with real or in-memory databases, and write assertions that actually catch regressions.

The Core Setup

NestJS's @nestjs/testing package provides Test.createTestingModule(). For e2e tests, you compile your full application module and pass it to Supertest's request() function:

// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    
    // Apply the same global pipes you use in main.ts
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }));

    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });
});

beforeAll — not beforeEach — because booting a NestJS app takes time. Boot once, run all tests, shut down once.

The critical detail: apply the same global middleware, pipes, guards, and interceptors that main.ts applies. If main.ts calls app.useGlobalPipes(new ValidationPipe()), your test setup must do the same. Otherwise your e2e tests run against a different configuration than production.

Writing HTTP Tests with Supertest

Supertest lets you describe HTTP requests and assert on responses in a fluent API:

describe('GET /users/:id', () => {
  it('returns a user by id', async () => {
    await request(app.getHttpServer())
      .get('/users/1')
      .expect(200)
      .expect((res) => {
        expect(res.body).toMatchObject({
          id: 1,
          email: expect.any(String),
          name: expect.any(String),
        });
        expect(res.body).not.toHaveProperty('password');
      });
  });

  it('returns 404 for non-existent user', async () => {
    await request(app.getHttpServer())
      .get('/users/99999')
      .expect(404)
      .expect((res) => {
        expect(res.body.message).toBe('User 99999 not found');
      });
  });
});

describe('POST /users', () => {
  it('creates a user and returns 201', async () => {
    const createDto = { email: 'new@example.com', name: 'New User', password: 'secure123' };

    const response = await request(app.getHttpServer())
      .post('/users')
      .send(createDto)
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(Number),
      email: 'new@example.com',
      name: 'New User',
    });
    expect(response.body).not.toHaveProperty('password');
  });

  it('returns 400 when email is missing', async () => {
    await request(app.getHttpServer())
      .post('/users')
      .send({ name: 'No Email' })
      .expect(400);
  });

  it('returns 400 when email is invalid', async () => {
    await request(app.getHttpServer())
      .post('/users')
      .send({ email: 'not-an-email', name: 'Bad Email' })
      .expect(400);
  });
});

app.getHttpServer() returns the underlying Node.js HTTP server. Supertest binds to it without occupying a port — tests can run in parallel without port conflicts.

Handling Authentication in E2E Tests

Most routes require authentication. Test the auth flow first, then reuse tokens:

describe('Authenticated endpoints', () => {
  let authToken: string;

  beforeAll(async () => {
    // Create a test user
    await request(app.getHttpServer())
      .post('/users')
      .send({
        email: 'testauth@example.com',
        name: 'Auth User',
        password: 'password123',
      });

    // Log in and capture the token
    const loginResponse = await request(app.getHttpServer())
      .post('/auth/login')
      .send({
        email: 'testauth@example.com',
        password: 'password123',
      })
      .expect(200);

    authToken = loginResponse.body.access_token;
  });

  it('GET /profile returns current user', async () => {
    await request(app.getHttpServer())
      .get('/profile')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200)
      .expect((res) => {
        expect(res.body.email).toBe('testauth@example.com');
      });
  });

  it('GET /profile returns 401 without token', async () => {
    await request(app.getHttpServer())
      .get('/profile')
      .expect(401);
  });

  it('GET /profile returns 401 with invalid token', async () => {
    await request(app.getHttpServer())
      .get('/profile')
      .set('Authorization', 'Bearer invalid.token.here')
      .expect(401);
  });
});

Never hardcode tokens. Derive them from the auth flow so tests stay valid when token format changes.

Database Strategy for E2E Tests

You have three options for database handling in e2e tests:

Option 1: In-memory SQLite. Fast, isolated, no external dependency. Works well for testing logic; doesn't catch PostgreSQL-specific behavior.

// test/app.e2e-spec.ts
import { TypeOrmModule } from '@nestjs/typeorm';

const moduleFixture = await Test.createTestingModule({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: ':memory:',
      entities: [User, Order, Product],
      synchronize: true,
    }),
    // ... other modules
  ],
}).compile();

Option 2: Real test database. Closest to production. Use environment variables to point at a dedicated test DB:

// Use separate test database
TypeOrmModule.forRoot({
  type: 'postgres',
  host: process.env.TEST_DB_HOST || 'localhost',
  port: Number(process.env.TEST_DB_PORT) || 5433,
  database: process.env.TEST_DB_NAME || 'myapp_test',
  synchronize: true, // or run migrations
  dropSchema: true,  // clean state before each test run
})

Option 3: Override specific repositories. Keep AppModule but swap out specific providers:

const moduleFixture = await Test.createTestingModule({
  imports: [AppModule],
})
.overrideProvider(UsersService)
.useValue(mockUsersService)
.compile();

.overrideProvider() replaces specific providers while keeping the rest of the module intact. Useful when you want real routing and pipes but mocked data access.

Testing Request/Response Serialization

E2E tests are ideal for verifying that response shapes match expectations — especially class-transformer serialization:

// Assuming User entity uses @Exclude() on password
it('never exposes password hash in response', async () => {
  const response = await request(app.getHttpServer())
    .get('/users/1')
    .set('Authorization', `Bearer ${authToken}`)
    .expect(200);

  expect(response.body).not.toHaveProperty('password');
  expect(response.body).not.toHaveProperty('passwordHash');
  expect(response.body).not.toHaveProperty('password_hash');
});

This test only works in e2e context — unit tests on the service wouldn't catch a misconfigured @Exclude() or missing ClassSerializerInterceptor.

Testing Validation Pipes

The ValidationPipe is global. E2E tests verify it's configured correctly:

describe('Input validation', () => {
  it('rejects extra fields when forbidNonWhitelisted is set', async () => {
    await request(app.getHttpServer())
      .post('/users')
      .send({
        email: 'valid@example.com',
        name: 'Valid User',
        password: 'secure123',
        adminOverride: true, // this field is not in the DTO
      })
      .expect(400);
  });

  it('transforms string id to number', async () => {
    // Route uses @Param('id', ParseIntPipe)
    await request(app.getHttpServer())
      .get('/users/abc')
      .expect(400); // not 500
  });

  it('returns structured validation errors', async () => {
    const response = await request(app.getHttpServer())
      .post('/users')
      .send({ name: '' }) // email missing, name empty
      .expect(400);

    expect(response.body.message).toEqual(expect.arrayContaining([
      expect.stringContaining('email'),
    ]));
  });
});

Testing File Uploads

Supertest handles multipart form data:

import * as path from 'path';

describe('POST /uploads', () => {
  it('uploads an image and returns URL', async () => {
    const response = await request(app.getHttpServer())
      .post('/uploads')
      .set('Authorization', `Bearer ${authToken}`)
      .attach('file', path.join(__dirname, 'fixtures', 'test-image.png'))
      .expect(201);

    expect(response.body).toMatchObject({
      url: expect.stringContaining('https://'),
      filename: expect.any(String),
    });
  });

  it('rejects non-image files', async () => {
    await request(app.getHttpServer())
      .post('/uploads')
      .set('Authorization', `Bearer ${authToken}`)
      .attach('file', path.join(__dirname, 'fixtures', 'test.pdf'))
      .expect(400);
  });
});

Database Cleanup Between Tests

State leaks between tests cause flaky, order-dependent test suites. Clear tables between runs:

// test/helpers/database.helper.ts
import { DataSource } from 'typeorm';

export async function clearDatabase(dataSource: DataSource): Promise<void> {
  const entities = dataSource.entityMetadatas;
  
  for (const entity of entities) {
    const repository = dataSource.getRepository(entity.name);
    await repository.query(`TRUNCATE "${entity.tableName}" CASCADE`);
  }
}
// In your test file
let dataSource: DataSource;

beforeAll(async () => {
  // ... setup
  dataSource = moduleFixture.get<DataSource>(DataSource);
});

beforeEach(async () => {
  await clearDatabase(dataSource);
});

Or use transactions that you roll back after each test — faster than truncating, but requires discipline:

beforeEach(async () => {
  await dataSource.query('BEGIN');
});

afterEach(async () => {
  await dataSource.query('ROLLBACK');
});

Running E2E Tests

NestJS projects generated with the CLI include a separate Jest config for e2e tests:

// jest-e2e.json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}
npm run test:e2e
<span class="hljs-comment"># expands to: jest --config ./test/jest-e2e.json

Run with verbose output to see individual test names:

npm run test:e2e -- --verbose

What E2E Tests Catch That Unit Tests Don't

  • Misconfigured global pipes (ValidationPipe not applied)
  • Wrong HTTP status codes from exception filters
  • Missing route handlers (typos in @Get() decorators)
  • Broken serialization (class-transformer configuration)
  • Guard order issues (authorization checked before authentication)
  • Middleware not running
  • CORS configuration
  • Request size limits

The rule: if it involves the HTTP layer, write an e2e test for it. Unit tests verify logic; e2e tests verify behavior.


Continuous e2e testing against your deployed NestJS API catches regressions that only appear in production environments. HelpMeTest schedules and runs test scenarios against live endpoints, alerting you when behavior changes unexpectedly — before users notice.

Read more