Kurs NestJS · Moduł 7: Testowanie

Integration Testing - sprawdzanie współpracy legionu

5 min czytania
W tej lekcji6

Każda część katapulty przeszła inspekcję. Lina wytrzymuje, mechanizm zwalnia, przeciwwaga waży tyle, ile trzeba. Składasz machinę - i pocisk leci trzy kroki. Dlaczego? Bo lina okazała się o metr za krótka dla tego ramienia. Każda część była dobra osobno, ale nikt nie sprawdził, czy do siebie pasują.

To jest luka, której test jednostkowy z założenia nie widzi - bo w nim wszystkie sąsiednie części zastąpiliśmy atrapami. Test integracyjny wypełnia tę lukę: składa kilka prawdziwych elementów i sprawdza, czy rozmawiają ze sobą tak, jak zakładaliśmy.

Piramida testów - ile czego pisać

Zanim napiszemy pierwszy taki test, ustalmy jego miejsce wśród innych. Testy układają się w piramidę - od najliczniejszych na dole po najrzadsze na górze:

  • Unit - podstawa piramidy. Najliczniejsze i najszybsze, bo nie dotykają niczego poza jedną klasą.
  • Integration - warstwa środkowa. Mniej liczne, wolniejsze, bo uruchamiają kilka elementów naraz.
  • E2E - wierzchołek. Najmniej liczne i najwolniejsze; przechodzą całą drogę żądania.
  • Testy ręczne - sporadyczne, tam gdzie automat nie sięga.

Kształt piramidy nie jest przypadkowy: im wyżej, tym test daje więcej pewności, ale kosztuje więcej czasu i psuje się z błahych powodów. Odwrócona piramida - garść testów jednostkowych i setki E2E - daje pakiet, który chodzi kwadrans i pada przy każdej zmianie tekstu na przycisku.

Ta sama kolejność rządzi uruchamianiem: npm run test (jednostkowe, sekundy) → test:watch (te same, w pętli podczas pisania) → test:cov (jednostkowe plus liczenie pokrycia) → test:e2e (najwolniejsze, na końcu).

Unit kontra integration - jedna różnica

Rozróżnienie sprowadza się do jednego pytania: ile prawdziwych elementów bierze udział w teście?

Test jednostkowy bada jeden komponent, a wszystkie jego zależności zastępuje atrapami. Test integracyjny bada współpracę wielu - kontroler razem z serwisem, serwis razem z repozytorium.

Nie znaczy to, że w testach integracyjnych atrapy znikają. Wciąż podmieniasz to, co leży poza granicą badanego wycinka: płatności, wysyłkę e-maili, cudze API. Przesuwasz jedynie granicę - z jednej klasy na kilka współpracujących.

Składanie modułu z prawdziwych części

Test integracyjny buduje moduł podobnie jak jednostkowy, ale zamiast wyliczać pojedyncze providery, importuje cały moduł aplikacji:

1const module = await Test.createTestingModule({
2  imports: [
3    TypeOrmModule.forRoot({
4      type: 'sqlite',
5      database: ':memory:',
6      entities: [Legion],
7      synchronize: true,
8    }),
9    LegionsModule,
10  ],
11}).compile();

LegionsModule wchodzi w całości - z kontrolerem, serwisem i repozytorium, tak jak w prawdziwej aplikacji. Nowością jest konfiguracja bazy: type: 'sqlite' z database: ':memory:' tworzy bazę istniejącą wyłącznie w pamięci procesu. Powstaje przy starcie testu i znika po jego zakończeniu, więc nie potrzebujesz żadnego serwera, a każde uruchomienie zaczyna od czystej tabeli.

synchronize: true każe TypeORM zbudować tabele wprost z encji. W aplikacji produkcyjnej to opcja zakazana - tam obowiązują migracje - ale w bazie żyjącej trzy sekundy jest dokładnie tym, czego potrzeba.

Test przez prawdziwe HTTP

Skoro w module jest kontroler, możemy zapukać do niego tak, jak zrobiłby to klient - żądaniem HTTP:

1describe('LegionsController (integracja)', () => {
2  let app: INestApplication;
3
4  beforeAll(async () => {
5    const module = await Test.createTestingModule({
6      imports: [/* ... */],
7    }).compile();
8
9    app = module.createNestApplication();
10    await app.init();
11  });
12
13  afterAll(async () => {
14    await app.close();
15  });
16
17  it('POST /legions zapisuje legion i zwraca go z identyfikatorem', async () => {
18    const response = await request(app.getHttpServer())
19      .post('/legions')
20      .send({ name: 'Legio X' })
21      .expect(201);
22
23    expect(response.body.id).toBeDefined();
24    expect(response.body.name).toBe('Legio X');
25  });
26});

Trzy nowe elementy warto nazwać. module.createNestApplication() zamienia skompilowany moduł w działającą aplikację - taką, która potrafi obsłużyć żądanie. app.init() ją uruchamia; bez tego wywołania serwer nie przyjmie żadnego zapytania.

Dalej request(app.getHttpServer()) - to biblioteka supertest. getHttpServer() wyciąga z aplikacji serwer HTTP, a request(...) wysyła do niego prawdziwe żądanie, bez otwierania portu. Ruch odbywa się w pamięci, więc test nie zajmuje sieci i nie zderzy się z innym procesem.

Łańcuch czyta się jak opis zapytania: .post('/legions') wybiera metodę i adres, .send({...}) dokłada ciało, .expect(201) sprawdza kod odpowiedzi. Zwrócone response.body badamy już zwykłymi asercjami.

I tu widać, co ten test daje ponad jednostkowy: pocisk przeleciał całą drogę - przez routing, walidację ciała, serwis, repozytorium, aż do bazy i z powrotem. Gdyby kontroler oczekiwał pola title, a serwis zapisywał name, test jednostkowy obu klas przeszedłby bez mrugnięcia; ten padnie.

Sprzątanie między testami

Prawdziwa baza, choćby w pamięci, pamięta wszystko, co do niej zapisano - także z poprzedniego testu:

1afterEach(async () => {
2  await app.get(getRepositoryToken(Legion)).clear();
3});

clear() opróżnia tabelę po każdym teście. Bez tego test liczący legiony zobaczy również te utworzone wcześniej i zacznie zależeć od kolejności uruchomienia - dokładnie ta sama pułapka, którą w testach jednostkowych rozbrajał beforeEach.

Zwróć uwagę na dobór haków: aplikację budujemy w beforeAll (raz, bo to kosztowne), a czyścimy dane w afterEach (za każdym razem, bo tanie). Zamknięcie app.close() w afterAll jest obowiązkowe - inaczej Jest zawiśnie z otwartym połączeniem do bazy.

Podsumowanie

Machina złożona i wystrzelona - tym razem naprawdę:

  • test integracyjny sprawdza współpracę wielu komponentów, gdy jednostkowy bada jeden w izolacji,
  • atrapy nie znikają - przesuwa się jedynie granica, za którą się je stawia,
  • piramida testów: najwięcej jednostkowych, mniej integracyjnych, najmniej E2E, sporadycznie ręczne,
  • kolejność od najszybszych: test, test:watch, test:cov, test:e2e,
  • moduł składamy z prawdziwych części przez imports: [Modul], a bazę zastępujemy sqlite :memory: z synchronize: true,
  • createNestApplication() robi z modułu działającą aplikację, app.init() ją uruchamia,
  • request(app.getHttpServer()) z biblioteki supertest wysyła prawdziwe żądanie bez otwierania portu,
  • łańcuch .post().send().expect(201) opisuje żądanie, a response.body badasz zwykłymi asercjami,
  • clear() w afterEach opróżnia tabelę, app.close() w afterAll zamyka aplikację - bez tego Jest zawiśnie.

W następnej lekcji wejdziemy na sam wierzchołek piramidy - do testów E2E, które przechodzą pełną drogę użytkownika. A na razie zapamiętaj: test jednostkowy mówi, że każda część działa; integracyjny mówi, że do siebie pasują.

Kod do tej lekcji: src/integration-testing.spec.ts
1// Integration Testing - Sprawdzanie Wspolpracy Legionu
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable } from '@nestjs/common';
4
5// Serwis zaopatrzenia
6@Injectable()
7class SupplyService {
8  calculateSupplies(soldiers: number): number {
9    // Kazdy zolnierz potrzebuje 3 jednostki zapasow
10    return soldiers * 3;
11  }
12
13  checkSupplyStatus(current: number, required: number) {
14    return {
15      sufficient: current >= required,
16      shortage: Math.max(0, required - current),
17    };
18  }
19}
20
21// Serwis garnizonu - zalezy od SupplyService
22@Injectable()
23class GarrisonService {
24  constructor(private supplyService: SupplyService) {}
25
26  assessReadiness(soldiers: number, currentSupplies: number) {
27    const requiredSupplies = this.supplyService.calculateSupplies(soldiers);
28    const status = this.supplyService.checkSupplyStatus(
29      currentSupplies, requiredSupplies
30    );
31
32    return {
33      soldiers,
34      requiredSupplies,
35      currentSupplies,
36      ready: status.sufficient,
37      shortage: status.shortage,
38    };
39  }
40}
41
42// Test integracyjny - sprawdza wspolprace serwisow
43describe('Garrison Integration Test', () => {
44  let garrisonService: GarrisonService;
45  let supplyService: SupplyService;
46
47  beforeEach(async () => {
48    // Tworzymy PRAWDZIWY modul z oboma serwisami
49    const module: TestingModule = await Test.createTestingModule({
50      providers: [GarrisonService, SupplyService],
51    }).compile();
52
53    garrisonService = module.get(GarrisonService);
54    supplyService = module.get(SupplyService);
55  });
56
57  it('should assess garrison as ready when supplies sufficient', () => {
58    const result = garrisonService.assessReadiness(100, 400);
59
60    expect(result.ready).toBe(true);
61    expect(result.shortage).toBe(0);
62    expect(result.requiredSupplies).toBe(300);
63  });
64
65  it('should detect supply shortage', () => {
66    const result = garrisonService.assessReadiness(100, 200);
67
68    expect(result.ready).toBe(false);
69    expect(result.shortage).toBe(100); // 300 - 200
70  });
71
72  it('should correctly integrate supply calculation', () => {
73    // Sprawdzamy przeplyw danych miedzy serwisami
74    const supplies = supplyService.calculateSupplies(50);
75    expect(supplies).toBe(150);
76
77    const readiness = garrisonService.assessReadiness(50, supplies);
78    expect(readiness.ready).toBe(true);
79  });
80});
81

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. Testy integracyjne w NestJS sprawdzają:

  2. 2. Główna różnica między unit a integration testami to:

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz test integracyjny dla modułu legionów

  • Układanie w pionie

    Uporządkuj piramidę testów od najliczniejszych (dół) do najrzadszych (góra):

  • Klikanie w kolejności

    Ułóż komendy uruchamiania testów od najszybszych:

Przydatne artykuły