InteractiveFrameworks

End-to-end Tests

Boot the real application inside a test and send it HTTP requests with supertest, asserting status codes, bodies and validation, the things a unit test cannot see.

What you'll learn

  • Create an application from a compiled testing module with createNestApplication() and init(), configured as main.ts configures it
  • Send requests with supertest and assert status, body and headers in its expect() forms
  • Manage one application per file with beforeAll and afterAll, and know what an e2e test proves that a unit test cannot

The unit tests so far proved that classes do what their methods say. None of them proved that POST /cats exists, that a body is validated before the handler runs, that a missing cat is a 404 and not a 500, or that the guard is applied to the route it should be. Those facts live in decorators and in the pipeline Nest runs around a handler, and they are only true once an application is built. An end-to-end test builds one and talks to it the way a client would: over HTTP, asserting status codes and bodies. It is slower than a unit test and it proves more.

Building the application in a test

The testing module can produce a whole application:

let app: INestApplication;

beforeAll(async () => {
  const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
  app = moduleRef.createNestApplication();
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  await app.init();
});

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

createNestApplication() is NestFactory.create() for a compiled module; init() wires the routes and runs the lifecycle hooks without listening on a port, since nothing will connect through a socket. Note the pipe. Whatever main.ts does to the application, the test has to do too, or the test exercises a different application from the one that ships. Teams that take this seriously move that setup into a function both call, configureApp(app), so it cannot drift.

One application per spec file is the rule: beforeAll builds it, afterAll closes it, and the tests share it. That makes the tests order-dependent within the file, which the docs accept and so does this lesson: a GET that expects the cat a POST created must come after it. Prefer tests that leave the application as they found it, or reset state in beforeEach, when the order starts to matter for the wrong reasons.

Sending requests

supertest sends requests to the application's server object and returns a chain:

it('POST /orders', () => {
  return request(app.getHttpServer())
    .post('/orders')
    .set('x-user', 'ada')
    .send({ item: 'book' })
    .expect(201)
    .expect({ id: 1, item: 'book' });
});

.get(), .post(), .put(), .patch(), .delete() start a request; .set() adds a header; .query() a query string; .send() a body, JSON when given an object. .expect() has four forms, applied in order: a status, expect(201); a body, deep-equal for JSON, expect({ id: 1 }); a header, expect('content-type', /json/); and a function that receives the response and may assert anything, expect((res) => { ... }). The chain is a promise: return it from the test, or await it and look at res.status, res.body, res.headers yourself. A failed expectation reads like expected 201 "Created", got 200 "OK".

What to assert

Assert what a client can observe: the status, the body, headers that matter. GET /cats/99 should be a 404 whose body says why; POST /cats with { name: '', age: -1 } should be a 400 whose message lists both rule failures, because that list is what a form will show. Do not assert internals, such as which service method ran; that is what the unit tests were for, and an e2e test that reaches into the container is a unit test with worse startup time.

Something slow or external at the bottom of the module, a database, a payment gateway, is where overrideProvider() comes back: import the real AppModule, swap the one provider that must not run, and everything above it is tested as shipped.

Your task

The application has a validated POST /cats, a GET /cats, and a GET /cats/:id with ParseIntPipe. Write test/cats.e2e-spec.ts:

  1. In beforeAll, compile a testing module that imports AppModule, create the application, apply a ValidationPipe with whitelist as main.ts would, and init it. Close it in afterAll.
  2. Write the five tests it lists: creating a cat, rejecting an invalid body with both messages, listing, a 404 with its message, and a 400 for a non-numeric id.

The bugs to catch are the ones only a request can see: a wrong status code, a cat returned but not stored, a 404 that became a 200, a pipe that is missing.

When it fails

  • request() needs the app's server: app.getHttpServer() was called before init(), or app was never assigned in beforeAll.
  • The invalid body is accepted with 201: the ValidationPipe is not applied in the test. The application under test is the one the test configures.
  • expected 200 "OK", got 404 "Not Found" on GET /cats: the test order, or the module: AppModule must import CatsModule for the routes to exist.
  • The tests hang and time out: the request was neither returned nor awaited, or beforeAll never called init().

Remember

  • createNestApplication() from a compiled module, then everything main.ts does, then init(); close() in afterAll.
  • request(app.getHttpServer()) then a verb, .set, .send, and .expect() for status, body, header or a function; return or await the chain.
  • Assert what a client sees; leave internals to unit tests.
  • One application per file, tests in a deliberate order, overrideProvider() for the one thing that must not run.
Stuck? Show a hint

beforeAll: const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init(). afterAll: await app.close(). Tests return request(app.getHttpServer()).post('/cats').send({...}).expect(201).expect({...}); for the message list, await the request and expect(res.body.message).toEqual([...]).