Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Integration Testing - sprawdzanie współpracy legionu

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

Przejdź do CodeWorlds