Kurs NestJS · Moduł 7: Testowanie
Integration Testing - sprawdzanie współpracy legionu
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:zsynchronize: 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, aresponse.bodybadasz zwykłymi asercjami, clear()wafterEachopróżnia tabelę,app.close()wafterAllzamyka 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});
81Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Testy integracyjne w NestJS sprawdzają:
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: