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.
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:
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).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.
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.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.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.Machina złożona i wystrzelona - tym razem naprawdę:
test, test:watch, test:cov, test:e2e,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,.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ą.