NestJS course Β· Module 7: Testing
PROJECT - comprehensive testing of the legion system
In this lesson7
Throughout this module we wrote tests one at a time: a service test here, a controller test there, an HTTP test somewhere else. The project is where they must become a suite - one that somebody runs six months from now and believes the result of.
Your task is to cover the legion management system with tests: legions and cohorts, assigning legionaries, planning expeditions, tracking tributes.
Three levels, three questions
Before you write the first file, settle what question each level answers:
- A unit test - does this method compute correctly when everything around it is mocked?
- An integration test - do the modules understand one another, with a real dependency container?
- An E2E test - does the full user path work over HTTP, from request to response?
Each level costs differently. Write the most unit tests, because they are cheap and point straight at the culprit; the fewest E2E, because they are slow and, when they fail, say only "something is wrong".
Given-When-Then
Every test, whatever its level, has the same threefold shape. Given is the preconditions, When is the action, Then is the expected result:
1it('should create a legion with the given name', async () => {
2 // Given - preconditions
3 const dto = { name: 'Legio X Equestris', maxSoldiers: 5000 };
4 mockRepository.save.mockResolvedValue({ id: 1, ...dto });
5
6 // When - the action
7 const result = await service.create(dto);
8
9 // Then - the expected result
10 expect(result.id).toBe(1);
11 expect(mockRepository.save).toHaveBeenCalledWith(dto);
12});Do not confuse this division with the three levels of testing - Given does not stand for a unit test, nor Then for an E2E one. Nor is it about kinds of test double: a mock, a spy and a stub are tools you use within the Given part. Nor about HTTP layers: data, request and response code are merely one possible filling of this skeleton.
The pattern's value is practical. When a test has no distinct When, it usually means it is checking two actions at once - and on failure you will not know which one gave way.
Two principles that decide whether a suite can be believed
Tests must be independent from each other and isolated. This is the first principle of unit testing and the only one whose breach spoils the whole suite at once.
Tests sharing state pass in the order they were written and fail once the order changes, once they run in parallel, or once somebody adds a test in the middle of the file. Worst of all, they then look as if they had caught a bug - when all they have caught is their own coupling.
Two misunderstandings are worth clearing away at once. Test names should be long and descriptive, not as short as possible: a test's name is the error message you will see on a red background in CI, and t1 will tell you nothing then. And edge cases must be tested too, not only the happy path - because bugs live exactly where nobody looked: an empty list, a zero, a special character in a name.
Hooks and mocking dependencies
Isolation is achieved with hooks, and their order of execution is fixed: beforeAll() β beforeEach() β test/it() β afterAll():
1describe('LegionsController', () => {
2 let controller: LegionsController;
3 let module: TestingModule;
4
5 const mockLegionsService = {
6 findAll: jest.fn(),
7 create: jest.fn(),
8 };
9
10 beforeAll(async () => {
11 module = await Test.createTestingModule({
12 controllers: [LegionsController],
13 providers: [{ provide: LegionsService, useValue: mockLegionsService }],
14 }).compile();
15
16 controller = module.get(LegionsController);
17 });
18
19 beforeEach(() => {
20 jest.clearAllMocks();
21 });
22
23 afterAll(async () => {
24 await module.close();
25 });
26});The division of labour between the hooks follows straight from their order. beforeAll runs once - here it builds the expensive testing module. beforeEach runs before every test, and it is the one that enforces isolation: jest.clearAllMocks() wipes the call history so that a test sees no traces of its predecessor. afterAll clears up at the end - closing the module, connections and open handles.
Note how the double is written in providers. The order is always the same: { provide: opens the object, LegionsService, names the token to be replaced, useValue: announces a value, and mockLegionsService } supplies it. It reads like a sentence: "in place of LegionsService, use this value".
Mocking a repository itself comes down to one line per case: mockRepository.save.mockResolvedValue(expectedResult) makes the method return a ready result instead of touching the database. For the error path you use mockRejectedValue.
The E2E test
At the highest level you speak to the application the way a client will - over HTTP, through the supertest library:
1it('GET /legions returns all legions', async () => {
2 const response = await request(app.getHttpServer())
3 .get('/legions')
4 .expect(200)
5 .expect((res) => expect(res.body).toHaveLength(3));
6});
7
8it('POST /legions rejects a body without a name', async () => {
9 await request(app.getHttpServer())
10 .post('/legions')
11 .send({ maxSoldiers: 5000 })
12 .expect(400);
13});The assertion is built of four links in a fixed order: const response = await request(app.getHttpServer()) opens a request to the running application, .get('/legions') names the method and path, .expect(200) checks the response code, and .expect(res => expect(res.body).toHaveLength(3)) looks inside its body.
The second test shows something easily forgotten: check the rejections too. A request without a required field must receive a 400, and that is the only way to be sure ValidationPipe really is wired into the test configuration and not merely into main.ts.
When a test fails
A red test is debugged in four steps, always in this order:
- Read the error message and the stack trace. It usually holds everything: what was expected, what came back, and on which line.
- Identify which assertion failed. With several
expectcalls in one test that is not obvious - hence the advice not to overload a test. - Check the input data and the mocks. The commonest cause lies not in the code but in a double returning something other than you think - or remembering a call from the previous test.
- Fix the test or the tested code. Only now, once it is clear what is broken.
The order matters, because the natural instinct - to start at step four and adjust the code by feel - ends in changing a working implementation to satisfy a faulty test.
What you hand in
The project is finished when it contains:
- Unit tests of the services with mocked repositories, covering the error paths as well.
- Integration tests checking that modules cooperate, on a real
TestingModule. - E2E tests for the key paths, with assertions on both the response code and the body.
- Separate
describeblocks for each level, so that they can be run independently. - A coverage report with a justification for whatever you deliberately left uncovered.
Finish with a trial that tests the whole suite at once: run the tests in random order (jest --randomize). If any of them fails, you have shared state somewhere - and a suite that passes in only one order is not telling the truth about your code.
Send the link to your repository when you are done.
Code for this lesson: src/legion-test-project.spec.ts
1// PROJECT: Comprehensive Testing of the Legion Management System
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable, NotFoundException } from '@nestjs/common';
4
5// === SERVICES TO TEST ===
6
7@Injectable()
8class CohortService {
9 private cohorts = new Map<string, any>();
10
11 create(data: { name: string; type: string; garrison: number }) {
12 if (!data.name || !data.type) {
13 throw new Error('Name and type are required');
14 }
15 const id = `cohort-${Date.now()}`;
16 const cohort = { id, ...data, status: 'docked' };
17 this.cohorts.set(id, cohort);
18 return cohort;
19 }
20
21 findOne(id: string) {
22 const cohort = this.cohorts.get(id);
23 if (!cohort) throw new NotFoundException(`Cohort ${id} not found`);
24 return cohort;
25 }
26
27 findAll() { return Array.from(this.cohorts.values()); }
28
29 deploy(id: string) {
30 const cohort = this.findOne(id);
31 cohort.status = 'deployed';
32 return cohort;
33 }
34}
35
36@Injectable()
37class LegionService {
38 constructor(private cohortService: CohortService) {}
39
40 getLegionStrength() {
41 const cohorts = this.cohortService.findAll();
42 return {
43 total: cohorts.length,
44 deployed: cohorts.filter(s => s.status === 'deployed').length,
45 docked: cohorts.filter(s => s.status === 'docked').length,
46 totalGarrison: cohorts.reduce((sum, s) => sum + s.garrison, 0),
47 };
48 }
49
50 deployLegion() {
51 const cohorts = this.cohortService.findAll();
52 return cohorts.map(s => this.cohortService.deploy(s.id));
53 }
54}
55
56// === TESTS ===
57
58describe('Legion Management - Complete Test Suite', () => {
59 // UNIT TESTS
60 describe('CohortService - Unit Tests', () => {
61 let service: CohortService;
62 beforeEach(() => { service = new CohortService(); });
63
64 it('should create a cohort', () => {
65 const cohort = service.create({
66 name: 'Trireme Roma', type: 'warcohort', garrison: 200,
67 });
68 expect(cohort.id).toBeDefined();
69 expect(cohort.status).toBe('docked');
70 });
71
72 it('should throw on invalid data', () => {
73 expect(() => service.create({
74 name: '', type: '', garrison: 0,
75 })).toThrow('Name and type are required');
76 });
77
78 it('should throw NotFoundException', () => {
79 expect(() => service.findOne('unknown')).toThrow(NotFoundException);
80 });
81
82 it('should deploy a cohort', () => {
83 const cohort = service.create({
84 name: 'Corvus', type: 'warcohort', garrison: 150,
85 });
86 const deployed = service.deploy(cohort.id);
87 expect(deployed.status).toBe('deployed');
88 });
89 });
90
91 // INTEGRATION TESTS
92 describe('LegionService - Integration', () => {
93 let legionService: LegionService;
94 let cohortService: CohortService;
95
96 beforeEach(async () => {
97 const module: TestingModule = await Test.createTestingModule({
98 providers: [LegionService, CohortService],
99 }).compile();
100 legionService = module.get(LegionService);
101 cohortService = module.get(CohortService);
102 });
103
104 it('should calculate legion strength', () => {
105 cohortService.create({ name: 'S1', type: 'war', garrison: 200 });
106 cohortService.create({ name: 'S2', type: 'war', garrison: 150 });
107 const strength = legionService.getLegionStrength();
108 expect(strength.total).toBe(2);
109 expect(strength.totalGarrison).toBe(350);
110 });
111
112 it('should deploy entire legion', () => {
113 cohortService.create({ name: 'S1', type: 'war', garrison: 200 });
114 cohortService.create({ name: 'S2', type: 'war', garrison: 150 });
115 const deployed = legionService.deployLegion();
116 expect(deployed.every(s => s.status === 'deployed')).toBe(true);
117 });
118 });
119});
120Spotted 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. The Given-When-Then pattern in tests means:
2. Which of the following principles is crucial when writing unit tests?
Hands-on tasks in the game
- Code editor
Test whether the controller rejects invalid input data
- Code editor
Write a test for the create() method with a mocked repository.save()
- Click in order
Arrange the order of hook execution in a test file:
- Vertical ordering
Arrange the assertion checking an HTTP response in an E2E test:
- Vertical ordering
Arrange the steps for debugging a failed test:
- Horizontal ordering
Arrange the mock provider configuration in TestingModule:
- Code editor
Write a full test suite covering the controller, service, and HTTP endpoint