NestJS course Β· Module 7: Testing
E2E Testing - simulating a full campaign
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.jsonThe 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' }); // failsThe 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.tssuffix, the configuration sits intest/jest-e2e.json, andnpm run test:e2eruns them, - four preparation steps:
createTestingModule({ imports: [AppModule] }),.compile(),createNestApplication(),await app.init(), - the global elements from
main.tsdo not come in automatically -ValidationPipeand 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,
toEqualcompares structure and values deeply - the choice for objects;toBechecks identity and suits only primitives,- to examine a controller alone, swap the service for a double and use
toHaveBeenCalledWithto check the arguments passed, app.close()inafterAllis 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});
98Spotted 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. When testing a NestJS controller, services should be:
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: