End-to-End Tests

Boot the real application in-process, drive it with supertest over its HTTP server, and get the whole request pipeline.

Syntaxconst app = moduleRef.createNestApplication(); await app.init(); request(app.getHttpServer())

An end-to-end test builds the real application and sends real HTTP requests through it. Nothing is skipped: middleware, guards, interceptors, pipes, the handler, the interceptor tails and the exception filters all run in that order, exactly as in production.

How it works

Compile a testing module that imports AppModule, then call moduleRef.createNestApplication(). That returns an INestApplication wrapping an HTTP adapter — Express by default. await app.init() runs the lifecycle hooks and registers every route on the adapter, but it does not bind a port. app.getHttpServer() returns the underlying Node server, and supertest drives that object directly.

So request(app.getHttpServer()).get('/orders') is the short form of await app.listen(0) followed by a request to the port it chose. Same pipeline, same handler, one less moving part. Always await app.close() afterwards, or the process keeps open sockets and database pools and the run hangs.

What trips people up

createNestApplication() does not execute main.ts. Global pipes, filters, interceptors, a global prefix, CORS, versioning and cookie parsing configured there are simply absent, so the test exercises a pipeline that does not exist anywhere else. Repeat that configuration in the test, or better, move it into one exported function that main.ts and every spec both call.

Under Vitest with ESM, import supertest as a default import: import request from 'supertest', not import * as request.

Example

Example · typescript
// test/orders.e2e-spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest'; // default import: Vitest/ESM rejects `import * as request`
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe, CanActivate } from '@nestjs/common';
import { AppModule } from '../src/app.module.js';
import { ApiKeyGuard } from '../src/auth/api-key.guard.js';

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

  // A guard is a class with canActivate(context): boolean | Promise<boolean>.
  // This one lets every request through so the specs do not need a real key.
  const allowAll: CanActivate = { canActivate: () => true };

  beforeAll(async () => {
    const moduleRef: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideGuard(ApiKeyGuard)
      .useValue(allowAll)
      .compile();

    app = moduleRef.createNestApplication();

    // main.ts is never executed, so its configuration is repeated here.
    // Omit these two lines and the pipeline under test is not the production one.
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
    app.setGlobalPrefix('api');

    await app.init(); // builds the adapter and routes; binds no port
  }, 30_000);        // image pulls and migrations can exceed the default hook timeout

  afterAll(async () => {
    await app.close(); // closes the server, the pools and the lifecycle hooks
  });

  it('rejects a body that fails validation with 400', async () => {
    const res = await request(app.getHttpServer())
      .post('/api/orders')
      .send({ sku: 'A-1', quantity: 'two' }) // quantity is declared as a number
      .expect(400);

    expect(res.body.message).toBeDefined(); // ValidationPipe lists the broken rules
  });

  it('creates an order and reads it back', async () => {
    const created = await request(app.getHttpServer())
      .post('/api/orders')
      .send({ sku: 'A-1', quantity: 2 })
      .expect(201);

    await request(app.getHttpServer())
      .get(`/api/orders/${created.body.id}`)
      .expect(200)
      .expect(({ body }) => expect(body.sku).toBe('A-1'));
  });
});

When to use it

  • Prove a new endpoint returns 201 and the documented body before shipping it.
  • Check that an invalid payload is rejected with 400 by the global ValidationPipe.
  • Verify an endpoint returns 401 without a token and 200 with one.
  • Reproduce a reported bug at the HTTP boundary, with the exact body the client sent.

More examples

One bootstrap function for main.ts and the tests

This removes the most common source of false-passing end-to-end tests. Because main.ts and the spec call the same function, a pipe or filter added for production is in the tests on the next run without anyone remembering to copy it. Adopt it the first time a test disagrees with production about a status code.

Example · typescript
// src/configure-app.ts
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import { HttpExceptionFilter } from './common/http-exception.filter.js';

// Everything that shapes the request pipeline lives here, in one place.
export function configureApp(app: INestApplication): INestApplication {
  app.setGlobalPrefix('api');
  app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, transform: true }),
  );
  app.useGlobalFilters(new HttpExceptionFilter());
  return app;
}

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { configureApp } from './configure-app.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  configureApp(app);
  await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

// test/orders.e2e-spec.ts (excerpt)
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module.js';
import { configureApp } from '../src/configure-app.js';

const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = configureApp(moduleRef.createNestApplication());
await app.init();

Fastify needs one extra await

Copy this whenever the application runs on @nestjs/platform-fastify. The adapter argument and the extra ready() await are the only differences from an Express end-to-end test, and the symptom of forgetting ready() is a 404 that appears in roughly one run out of several - the hardest kind of flake to diagnose.

Example · typescript
// test/cats.e2e-spec.ts
import { beforeAll, afterAll, expect, it } from 'vitest';
import request from 'supertest';
import { Test, TestingModule } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from '../src/app.module.js';

let app: NestFastifyApplication;

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

  // The adapter is passed to createNestApplication, and the generic names the
  // application type so Fastify-only methods are visible.
  app = moduleRef.createNestApplication<NestFastifyApplication>(
    new FastifyAdapter(),
  );
  app.useGlobalPipes(new ValidationPipe());

  await app.init();
  // Fastify builds its router lazily. Without ready(), requests race the
  // route registration and 404 intermittently.
  await app.getHttpAdapter().getInstance().ready();
}, 30_000);

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

it('GET /cats', async () => {
  const res = await request(app.getHttpServer()).get('/cats').expect(200);
  expect(res.body).toMatchObject({ data: [] });
});

Logging in once and reusing the token

Overriding the guard is right when auth is incidental to what you are testing. When auth is the thing you are testing, do not override it: log in through the real endpoint and carry the token. One suite in the project should work this way, so that a broken JWT secret, a misconfigured strategy or a guard removed by accident fails a test instead of reaching production.

Example · typescript
// test/auth-flow.e2e-spec.ts
import { beforeAll, afterAll, expect, it } from 'vitest';
import request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module.js';
import { configureApp } from '../src/configure-app.js';

let app: INestApplication;
let token: string;

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

  app = configureApp(moduleRef.createNestApplication());
  await app.init();

  // configureApp sets the global prefix and URI versioning with defaultVersion '1',
  // and URI versioning inserts the version after the prefix and before the route,
  // so every path below is /api/v1/... - '/api/auth/login' would 404.
  // No guard override here: the point of this suite is the real auth pipeline.
  const login = await request(app.getHttpServer())
    .post('/api/v1/auth/login')
    .send({ email: '[email protected]', password: 'correct horse' })
    .expect(201);

  token = login.body.accessToken;
}, 30_000);

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

it('refuses an unauthenticated request', async () => {
  await request(app.getHttpServer()).get('/api/v1/orders').expect(401);
});

it('accepts the bearer token issued at login', async () => {
  const res = await request(app.getHttpServer())
    .get('/api/v1/orders')
    .set('Authorization', `Bearer ${token}`)
    .expect(200);

  expect(Array.isArray(res.body)).toBe(true);
});

Discussion

  • Be the first to comment on this lesson.