Kurs NestJS · Moduł 7: Testowanie

PROJEKT - kompleksowe testowanie systemu legionów

5 min czytania
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:

  1. Przeczytaj komunikat błędu i stack trace. Jest w nim zwykle wszystko: czego oczekiwano, co otrzymano, i w której linii.
  2. Zidentyfikuj, która asercja zawiodła. Przy kilku expect w jednym teście to nie jest oczywiste - stąd zalecenie, by testów nie przeciążać.
  3. 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.
  4. 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:

  1. Testy jednostkowe serwisów z zamockowanymi repozytoriami, pokrywające także ścieżki błędów.
  2. Testy integracyjne sprawdzające współpracę modułów na prawdziwym TestingModule.
  3. Testy E2E dla kluczowych ścieżek, z asercjami na kodzie odpowiedzi i na treści.
  4. Osobne bloki describe dla każdego poziomu, tak by dało się je uruchamiać niezależnie.
  5. 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});
120

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Wzorzec Given-When-Then w testach oznacza:

  2. 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

Przydatne artykuły