NestJS course Β· Module 7: Testing

E2E Testing - simulating a full campaign

6 min read
In this lesson6

The catapult fires, the parts fit together. But a campaign is not one shot: the scouts find the enemy, a messenger carries the order, the legion forms up, the machines strike, the camp packs at dusk. Checking each stage separately will not tell you whether the whole expedition reaches its end.

An E2E test - end to end - walks that road in full. It starts a real application, sends real requests and checks what a user would see. No doubles: if validation, authentication or a database write fails along the way, the test catches it.

Where E2E tests live

We keep E2E tests apart from the rest, because they have their own configuration and their own pace:

1test/
2  app.e2e-spec.ts
3  jest-e2e.json

The test file carries the .e2e-spec.ts suffix, and their Jest configuration sits in test/jest-e2e.json. It tells Jest to look for files by a different pattern and sets a longer timeout, because the full road takes longer than calling a single method. You run them with a separate command, npm run test:e2e, precisely so they do not slow down the quick unit run.

Four steps to a running application

Preparing the application always goes the same way, and the order is worth knowing:

1describe('Legions (e2e)', () => {
2  let app: INestApplication;
3
4  beforeAll(async () => {
5    const moduleFixture = await Test.createTestingModule({
6      imports: [AppModule],
7    }).compile();
8
9    app = moduleFixture.createNestApplication();
10    app.useGlobalPipes(new ValidationPipe());
11    await app.init();
12  });
13
14  afterAll(async () => {
15    await app.close();
16  });
17});

Step one: Test.createTestingModule({ imports: [AppModule] }) - we import AppModule, the root of the whole application. That is the difference from an integration test, where we took a single module: here everything comes in.

Step two: .compile() resolves the dependencies and creates the instances.

Step three: createNestApplication() turns the module into an application able to handle a request.

Step four: await app.init() starts it.

Between steps three and four there is room for one thing that is easily forgotten: the global elements normally configured in main.ts. ValidationPipe, exception filters, a route prefix - none of them comes in automatically, because main.ts never runs in a test. If you leave them out, the test application will accept data production would reject - and the test passes falsely.

Walking the whole road

With the application in place, we send requests with the supertest library - the same one you met in integration tests:

1it('creates a legion and lets you fetch it', async () => {
2  const created = await request(app.getHttpServer())
3    .post('/legions')
4    .send({ name: 'Legio I' })
5    .expect(201);
6
7  await request(app.getHttpServer())
8    .get(`/legions/${created.body.id}`)
9    .expect(200)
10    .expect((res) => {
11      expect(res.body).toEqual({
12        id: created.body.id,
13        name: 'Legio I',
14      });
15    });
16});

The chain reads like a description of a conversation with the server: request(app.getHttpServer()) takes the application's server, .post('/legions') picks the method and address, .send({...}) adds the request body, .expect(201) checks the response code. Remember that order - it is the skeleton of every E2E test.

But the most interesting part is elsewhere: the second request uses the result of the first. We saved a legion and then fetched it by the identifier the server returned. That is what no lower-level test will do - we are checking not a single endpoint but a user scenario made of several steps.

toEqual versus toBe

In that last assertion we used toEqual, and that is no accident:

1expect(res.body).toEqual({ id: 1, name: 'Legio I' });   // passes
2expect(res.body).toBe({ id: 1, name: 'Legio I' });      // fails

The difference is fundamental. toBe asks whether it is the same object - the same cell of memory. The server's response arrived over the wire and was rebuilt from JSON, so it will never be the same object as your literal; toBe always fails here.

toEqual compares deeply: it walks the structure and checks the value of every field. For objects and arrays that is the right choice. Leave toBe for numbers, strings and booleans, where "the same" and "equal" mean one thing.

When not E2E - a controller in isolation

The full road gives the most confidence but costs the most time. When you want to check the controller's own logic - whether it passes the right arguments on - build it with a mocked service:

1const mockService = { findAll: jest.fn().mockResolvedValue([]) };
2
3const module = await Test.createTestingModule({
4  controllers: [LegionsController],
5  providers: [{ provide: LegionsService, useValue: mockService }],
6}).compile();
7
8const controller = module.get(LegionsController);
9
10it('passes the status filter to the service', async () => {
11  await controller.findAll('active');
12
13  expect(mockService.findAll).toHaveBeenCalledWith({ status: 'active' });
14});

The service is a double here, so we examine the controller alone. The toHaveBeenCalledWith assertion checks which arguments the double was called with - and that is the only way to confirm the controller correctly translated a query parameter into a filter object. The return value does not interest us here; the conversation between layers does.

Choosing between the two comes down to one question: am I checking one layer's logic or the road through all of them. E2E for scenarios, isolation for details.

Summary

The campaign has travelled the whole road:

  • an E2E test starts a real application and walks a request's full road, with no doubles,
  • files carry the .e2e-spec.ts suffix, the configuration sits in test/jest-e2e.json, and npm run test:e2e runs them,
  • four preparation steps: createTestingModule({ imports: [AppModule] }), .compile(), createNestApplication(), await app.init(),
  • the global elements from main.ts do not come in automatically - ValidationPipe and filters must be added, or the test passes falsely,
  • the supertest chain: request(app.getHttpServer()), .post('/address'), .send({...}), .expect(201),
  • E2E's strength is a multi-step scenario where each request uses the previous one's result,
  • toEqual compares structure and values deeply - the choice for objects; toBe checks identity and suits only primitives,
  • to examine a controller alone, swap the service for a double and use toHaveBeenCalledWith to check the arguments passed,
  • app.close() in afterAll is mandatory.

In the next lesson we will look at the doubles themselves - you will meet the four kinds and the difference between jest.fn() and jest.spyOn(). For now remember: E2E checks whether the campaign reaches its end; isolation checks whether a single messenger carries the right order.

Code for this lesson: src/e2e-testing.spec.ts
1// E2E Testing - Simulating a Full Campaign
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { Controller, Get, Post, Body, Param, Module } from '@nestjs/common';
6import { Injectable } from '@nestjs/common';
7
8// Legions service
9@Injectable()
10class LegionService {
11  private legions = [
12    { id: 1, name: 'Legio X', province: 'Gallia', soldiers: 5000 },
13  ];
14
15  findAll() { return this.legions; }
16  findOne(id: number) { return this.legions.find(l => l.id === id); }
17  create(data: any) {
18    const legion = { id: this.legions.length + 1, ...data };
19    this.legions.push(legion);
20    return legion;
21  }
22}
23
24// Controller
25@Controller('legions')
26class LegionController {
27  constructor(private service: LegionService) {}
28
29  @Get()
30  findAll() { return this.service.findAll(); }
31
32  @Get(':id')
33  findOne(@Param('id') id: string) {
34    return this.service.findOne(parseInt(id));
35  }
36
37  @Post()
38  create(@Body() data: any) {
39    return this.service.create(data);
40  }
41}
42
43// Module
44@Module({
45  controllers: [LegionController],
46  providers: [LegionService],
47})
48class LegionModule {}
49
50// E2E tests
51describe('Legion E2E Tests', () => {
52  let app: INestApplication;
53
54  beforeAll(async () => {
55    const moduleFixture: TestingModule = await Test.createTestingModule({
56      imports: [LegionModule],
57    }).compile();
58
59    app = moduleFixture.createNestApplication();
60    app.useGlobalPipes(new ValidationPipe());
61    await app.init();
62  });
63
64  afterAll(async () => {
65    await app.close();
66  });
67
68  it('GET /legions - should return all legions', () => {
69    return request(app.getHttpServer())
70      .get('/legions')
71      .expect(200)
72      .expect((res) => {
73        expect(Array.isArray(res.body)).toBe(true);
74        expect(res.body.length).toBeGreaterThan(0);
75      });
76  });
77
78  it('POST /legions - should create a legion', () => {
79    return request(app.getHttpServer())
80      .post('/legions')
81      .send({ name: 'Legio XIV', province: 'Pannonia', soldiers: 4500 })
82      .expect(201)
83      .expect((res) => {
84        expect(res.body.name).toBe('Legio XIV');
85        expect(res.body.id).toBeDefined();
86      });
87  });
88
89  it('GET /legions/:id - should return single legion', () => {
90    return request(app.getHttpServer())
91      .get('/legions/1')
92      .expect(200)
93      .expect((res) => {
94        expect(res.body.name).toBe('Legio X');
95      });
96  });
97});
98

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. When testing a NestJS controller, services should be:

  2. 2. expect(result).toEqual(expected) checks:

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Test the GET endpoint of the legions controller

  • Horizontal ordering

    Arrange the assertion checking a mock call with arguments:

  • Code editor

    Create an E2E test for the GET /legions endpoint

  • Vertical ordering

    Arrange the E2E test steps with supertest in the correct order:

  • Vertical ordering

    Arrange the steps for preparing the application for an E2E test:

Useful articles