Kurs NestJS · Moduł 7: Testowanie
PROJEKT - kompleksowe testowanie systemu legionów
W tej lekcji7
Przez cały moduł pisaliśmy testy pojedynczo: tu test serwisu, tam test kontrolera, gdzie indziej test przez HTTP. Projekt jest miejscem, w którym mają stać się zestawem - takim, który ktoś uruchomi za pół roku i uwierzy w jego wynik.
Twoim zadaniem jest pokrycie testami systemu zarządzania legionami: legiony i kohorty, przypisywanie legionistów, planowanie wypraw, śledzenie tributów.
Trzy poziomy, trzy pytania
Zanim napiszesz pierwszy plik, ustal, na jakie pytanie odpowiada każdy poziom:
- Test jednostkowy - czy ta metoda liczy poprawnie, gdy wszystko dookoła jest zamockowane?
- Test integracyjny - czy moduły dogadują się między sobą, z prawdziwym kontenerem zależności?
- Test E2E - czy pełna ścieżka użytkownika działa przez HTTP, od żądania do odpowiedzi?
Każdy poziom kosztuje inaczej. Jednostkowych pisz najwięcej, bo są tanie i wskazują palcem winowajcę; E2E najmniej, bo są wolne, a gdy padają, mówią tylko „coś jest nie tak".
Given-When-Then
Każdy test, niezależnie od poziomu, ma tę samą trójdzielną budowę. Given to warunki wstępne, When to akcja, Then to oczekiwany wynik:
1it('should create a legion with the given name', async () => {
2 // Given - warunki wstępne
3 const dto = { name: 'Legio X Equestris', maxSoldiers: 5000 };
4 mockRepository.save.mockResolvedValue({ id: 1, ...dto });
5
6 // When - akcja
7 const result = await service.create(dto);
8
9 // Then - oczekiwany wynik
10 expect(result.id).toBe(1);
11 expect(mockRepository.save).toHaveBeenCalledWith(dto);
12});Nie myl tego podziału z trzema poziomami testów - Given nie oznacza testu jednostkowego, a Then testu E2E. Nie chodzi też o rodzaje atrap: mock, spy i stub to narzędzia, których używasz w części Given. Ani o warstwy HTTP: dane, żądanie i kod odpowiedzi są tylko jednym z możliwych wypełnień tego szkieletu.
Wartość wzorca jest praktyczna. Gdy w teście brakuje wyraźnego When, zwykle znaczy to, że test sprawdza dwie akcje naraz - i przy porażce nie będziesz wiedzieć, która zawiodła.
Dwie zasady, które decydują o wiarygodności
Testy muszą być niezależne od siebie i izolowane. To pierwsza zasada testowania jednostkowego i jedyna, której złamanie psuje cały zestaw naraz.
Testy dzielące stan przechodzą w kolejności, w jakiej je napisano, a padają po zmianie kolejności, po uruchomieniu równoległym albo po dopisaniu jednego testu w środku pliku. Najgorsze, że wyglądają wtedy na wykrywające błąd - a wykrywają tylko własne powiązanie.
Dwa nieporozumienia warto od razu odsunąć. Nazwy testów mają być długie i opisowe, nie jak najkrótsze: nazwa testu jest komunikatem błędu, który zobaczysz na czerwonym tle w CI, i t1 nie powie ci wtedy nic. Testować trzeba też przypadki brzegowe (edge cases), nie tylko ścieżkę pozytywną (happy path) - bo błędy mieszkają dokładnie tam, gdzie nikt nie zaglądał: pusta lista, wartość zero, znak specjalny w nazwie.
Hooki i mockowanie zależności
Izolację zapewniają hooki, a ich kolejność wykonania jest stała: 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});Podział pracy między hookami wynika wprost z ich kolejności. beforeAll wykonuje się raz - tu buduje się kosztowny moduł testowy. beforeEach wykonuje się przed każdym testem i to on realizuje zasadę izolacji: jest.clearAllMocks() kasuje historię wywołań, żeby test nie widział śladów po poprzedniku. afterAll sprząta na końcu - zamyka moduł, połączenia, otwarte uchwyty.
Zwróć uwagę na zapis atrapy w providers. Kolejność jest zawsze ta sama: { provide: otwiera obiekt, LegionsService, wskazuje token do podmiany, useValue: zapowiada wartość, a mockLegionsService } ją podaje. Czyta się to jak zdanie: „w miejsce LegionsService użyj tej oto wartości".
Samo mockowanie repozytorium sprowadza się do jednej linijki na przypadek: mockRepository.save.mockResolvedValue(expectedResult) sprawia, że metoda zwróci gotowy wynik zamiast dotykać bazy. Do sprawdzenia ścieżki błędu użyjesz mockRejectedValue.
Test E2E
Na najwyższym poziomie mówisz do aplikacji tak, jak zrobi to klient - przez HTTP, biblioteką supertest:
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});Asercja składa się z czterech ogniw w stałej kolejności: const response = await request(app.getHttpServer()) otwiera żądanie do działającej aplikacji, .get('/legions') wskazuje metodę i ścieżkę, .expect(200) sprawdza kod odpowiedzi, a .expect(res => expect(res.body).toHaveLength(3)) zagląda do jej treści.
Drugi test pokazuje rzecz, o której łatwo zapomnieć: sprawdzaj też odrzucenia. Żądanie bez wymaganego pola musi dostać 400, i to jedyny sposób, by upewnić się, że ValidationPipe jest naprawdę podpięty w konfiguracji testowej, a nie tylko w main.ts.
Gdy test nie przechodzi
Czerwony test debuguje się w czterech krokach, zawsze w tej kolejności:
- Przeczytaj komunikat błędu i stack trace. Jest w nim zwykle wszystko: czego oczekiwano, co otrzymano, i w której linii.
- Zidentyfikuj, która asercja zawiodła. Przy kilku
expectw jednym teście to nie jest oczywiste - stąd zalecenie, by testów nie przeciążać. - Sprawdź dane wejściowe i mocki. Najczęstsza przyczyna nie leży w kodzie, tylko w atrapie, która zwraca co innego, niż myślisz - albo pamięta wywołanie z poprzedniego testu.
- Napraw test albo testowany kod. Dopiero teraz, gdy wiadomo, co jest zepsute.
Kolejność ma znaczenie, bo naturalny odruch - zacząć od czwartego kroku i poprawiać kod na wyczucie - kończy się zmianą działającej implementacji pod błędny test.
Co oddajesz
Projekt jest gotowy, gdy zawiera:
- Testy jednostkowe serwisów z zamockowanymi repozytoriami, pokrywające także ścieżki błędów.
- Testy integracyjne sprawdzające współpracę modułów na prawdziwym
TestingModule. - Testy E2E dla kluczowych ścieżek, z asercjami na kodzie odpowiedzi i na treści.
- Osobne bloki
describedla każdego poziomu, tak by dało się je uruchamiać niezależnie. - Raport pokrycia z uzasadnieniem tego, czego świadomie nie pokryłeś.
Na koniec wykonaj próbę, która sprawdza cały zestaw naraz: uruchom testy w losowej kolejności (jest --randomize). Jeśli którykolwiek padnie, masz gdzieś współdzielony stan - a zestaw, który przechodzi tylko w jednej kolejności, nie mówi prawdy o kodzie.
Prześlij link do repozytorium, gdy skończysz.
Kod do tej lekcji: src/legion-test-project.spec.ts
1// PROJEKT: Kompleksowe Testowanie Systemu Zarzadzania Legion
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable, NotFoundException } from '@nestjs/common';
4
5// === SERWISY DO PRZETESTOWANIA ===
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// === TESTY ===
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});
120Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Wzorzec Given-When-Then w testach oznacza:
2. Która z poniższych zasad jest kluczowa przy pisaniu testów jednostkowych?
Zadania praktyczne w grze
- Edytor kodu
Przetestuj czy kontroler odrzuca niepoprawne dane wejściowe
- Edytor kodu
Napisz test metody create() z zamockowanym repository.save()
- Klikanie w kolejności
Ułóż kolejność wykonywania hooków w pliku testowym:
- Układanie w pionie
Ułóż asercję sprawdzającą odpowiedź HTTP w teście E2E:
- Układanie w pionie
Uporządkuj kroki debugowania niezdanego testu:
- Układanie w poziomie
Ułóż konfigurację mock providera w TestingModule:
- Edytor kodu
Napisz pełen zestaw testów obejmujący kontroler, serwis i endpoint HTTP