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.
Testy E2E trzymamy osobno od reszty, bo mają własną konfigurację i własne tempo:
1test/
2 app.e2e-spec.ts
3 jest-e2e.jsonPlik z testami ma przyrostek
, a konfiguracja Jest dla nich leży w .e2e-spec.ts
. 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 test/jest-e2e.json
npm run test:e2e, właśnie po to, żeby nie spowalniały szybkiego przebiegu testów jednostkowych.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.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.
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' }); // padaRóżnica jest zasadnicza.
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
toBe zawsze tu padnie.
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. toEqual
toBe zostaw dla liczb, tekstów i wartości logicznych, gdzie „ten sam" i „równy" znaczą to samo.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.
Kampania przeszła całą drogę:
.e2e-spec.ts, konfiguracja leży w test/jest-e2e.json, uruchamia je npm run test:e2e,createTestingModule({ imports: [AppModule] }), .compile(), createNestApplication(), await app.init(),main.ts nie wchodzą automatycznie - ValidationPipe i filtry trzeba dopisać, inaczej test przejdzie fałszywie,request(app.getHttpServer()), .post('/adres'), .send({...}), .expect(201),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,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.