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

E2E Testing - symulacja pełnej kampanii

Katapulta strzela, części do siebie pasują. Ale kampania to nie jeden wystrzał: zwiad odnajduje wroga, posłaniec niesie rozkaz, legion się ustawia, machiny biją, obóz zwija się o zmierzchu. Sprawdzenie każdego etapu osobno nie powie Ci, czy cała wyprawa dojdzie do końca.

Test E2E - od końca do końca - przechodzi tę drogę w całości. Uruchamia prawdziwą aplikację, wysyła prawdziwe żądania i sprawdza to, co zobaczyłby użytkownik. Żadnych atrap: jeśli po drodze zawiedzie walidacja, uwierzytelnienie albo zapis do bazy, test to wyłapie.

Gdzie mieszkają testy E2E

Testy E2E trzymamy osobno od reszty, bo mają własną konfigurację i własne tempo:

1test/
2  app.e2e-spec.ts
3  jest-e2e.json

Plik z testami ma przyrostek

.e2e-spec.ts
, a konfiguracja Jest dla nich leży w
test/jest-e2e.json
. To ona każe Jest szukać plików po innym wzorcu i ustawia dłuższy limit czasu, bo pełna droga trwa dłużej niż wywołanie jednej metody. Uruchamiasz je osobnym poleceniem
npm run test:e2e
, właśnie po to, żeby nie spowalniały szybkiego przebiegu testów jednostkowych.

Cztery kroki do działającej aplikacji

Przygotowanie aplikacji przebiega zawsze tak samo i warto znać kolejność:

1describe('Legions (e2e)', () => {
2  let app: INestApplication;
3
4  beforeAll(async () => {
5    const moduleFixture = await Test.createTestingModule({
6      imports: [AppModule],
7    }).compile();
8
9    app = moduleFixture.createNestApplication();
10    app.useGlobalPipes(new ValidationPipe());
11    await app.init();
12  });
13
14  afterAll(async () => {
15    await app.close();
16  });
17});

Krok pierwszy:

Test.createTestingModule({ imports: [AppModule] })
- importujemy
AppModule
, czyli korzeń całej aplikacji. To jest różnica wobec testu integracyjnego, gdzie braliśmy pojedynczy moduł: tu wchodzi wszystko.

Krok drugi:

.compile()
rozwiązuje zależności i tworzy instancje.

Krok trzeci:

createNestApplication()
zamienia moduł w aplikację zdolną obsłużyć żądanie.

Krok czwarty:

await app.init()
ją uruchamia.

Między trzecim a czwartym krokiem jest miejsce na jedną rzecz, o której łatwo zapomnieć: globalne elementy konfigurowane zwykle w

main.ts
.
ValidationPipe
, filtry wyjątków, prefiks tras - żadne z nich nie wchodzi automatycznie, bo
main.ts
w teście się nie wykonuje. Jeśli tego nie dopiszesz, aplikacja testowa przyjmie dane, które produkcja odrzuciłaby - i test przejdzie fałszywie.

Przejście pełnej drogi

Mając aplikację, wysyłamy żądania biblioteką supertest - tą samą, którą poznałeś przy testach integracyjnych:

1it('tworzy legion i pozwala go pobrać', async () => {
2  const created = await request(app.getHttpServer())
3    .post('/legions')
4    .send({ name: 'Legio I' })
5    .expect(201);
6
7  await request(app.getHttpServer())
8    .get(`/legions/${created.body.id}`)
9    .expect(200)
10    .expect((res) => {
11      expect(res.body).toEqual({
12        id: created.body.id,
13        name: 'Legio I',
14      });
15    });
16});

Łańcuch czyta się jak opis rozmowy z serwerem:

request(app.getHttpServer())
bierze serwer aplikacji,
.post('/legions')
wybiera metodę i adres,
.send({...})
dokłada ciało żądania,
.expect(201)
sprawdza kod odpowiedzi. Zapamiętaj tę kolejność - to szkielet każdego testu E2E.

Ale najciekawsze jest tu coś innego: drugie żądanie korzysta z wyniku pierwszego. Zapisaliśmy legion, a potem pobraliśmy go po identyfikatorze zwróconym przez serwer. Właśnie tego nie zrobi żaden test niższego poziomu - sprawdzamy nie pojedynczy endpoint, tylko scenariusz użytkownika złożony z kilku kroków.

toEqual kontra toBe

W ostatniej asercji użyliśmy

toEqual
i to nie jest przypadek:

1expect(res.body).toEqual({ id: 1, name: 'Legio I' });   // przechodzi
2expect(res.body).toBe({ id: 1, name: 'Legio I' });      // pada

Różnica jest zasadnicza.

toBe
pyta, czy to ten sam obiekt - ta sama komórka pamięci. Odpowiedź serwera przyszła po sieci i została odtworzona z JSON-a, więc nigdy nie będzie tym samym obiektem co Twój literał;
toBe
zawsze tu padnie.

toEqual
porównuje głęboko: schodzi po strukturze i sprawdza wartość każdego pola. Dla obiektów i tablic to jest właściwy wybór.
toBe
zostaw dla liczb, tekstów i wartości logicznych, gdzie „ten sam" i „równy" znaczą to samo.

Kiedy nie E2E - kontroler w izolacji

Pełna droga daje najwięcej pewności, ale kosztuje najwięcej czasu. Gdy chcesz sprawdzić samą logikę kontrolera - czy przekazuje właściwe argumenty dalej - zbuduj go z zamockowanym serwisem:

1const mockService = { findAll: jest.fn().mockResolvedValue([]) };
2
3const module = await Test.createTestingModule({
4  controllers: [LegionsController],
5  providers: [{ provide: LegionsService, useValue: mockService }],
6}).compile();
7
8const controller = module.get(LegionsController);
9
10it('przekazuje filtr statusu do serwisu', async () => {
11  await controller.findAll('active');
12
13  expect(mockService.findAll).toHaveBeenCalledWith({ status: 'active' });
14});

Serwis jest tu atrapą, więc badamy wyłącznie kontroler. Asercja

toHaveBeenCalledWith
sprawdza, z jakimi argumentami wywołano atrapę - a to jedyny sposób, by potwierdzić, że kontroler poprawnie przetłumaczył parametr zapytania na obiekt filtra. Wynik nas tu nie interesuje; interesuje rozmowa między warstwami.

Wybór między jednym a drugim sprowadza się do pytania: czy sprawdzam logikę jednej warstwy, czy drogę przez wszystkie. E2E dla scenariuszy, izolacja dla szczegółów.

Podsumowanie

Kampania przeszła całą drogę:

  • test E2E uruchamia prawdziwą aplikację i przechodzi pełną drogę żądania, bez atrap,
  • pliki mają przyrostek
    .e2e-spec.ts
    , konfiguracja leży w
    test/jest-e2e.json
    , uruchamia je
    npm run test:e2e
    ,
  • cztery kroki przygotowania:
    createTestingModule({ imports: [AppModule] })
    ,
    .compile()
    ,
    createNestApplication()
    ,
    await app.init()
    ,
  • globalne elementy z
    main.ts
    nie wchodzą automatycznie
    -
    ValidationPipe
    i filtry trzeba dopisać, inaczej test przejdzie fałszywie,
  • łańcuch supertest:
    request(app.getHttpServer())
    ,
    .post('/adres')
    ,
    .send({...})
    ,
    .expect(201)
    ,
  • siła E2E to scenariusz z wielu kroków, gdzie kolejne żądanie korzysta z wyniku poprzedniego,
  • toEqual
    porównuje głęboko
    strukturę i wartości - to wybór dla obiektów;
    toBe
    sprawdza tożsamość i pasuje tylko do wartości prostych,
  • do zbadania samego kontrolera podmień serwis atrapą i użyj
    toHaveBeenCalledWith
    , żeby sprawdzić przekazane argumenty,
  • app.close()
    w
    afterAll
    jest obowiązkowe.

W następnej lekcji przyjrzymy się bliżej samym atrapom - poznasz cztery rodzaje dublerów i różnicę między

jest.fn()
a
jest.spyOn()
. A na razie zapamiętaj: E2E sprawdza, czy kampania dochodzi do końca; izolacja sprawdza, czy pojedynczy posłaniec niesie właściwy rozkaz.

Przejdź do CodeWorlds