Kurs NestJS · Moduł 7: Testowanie

E2E Testing - symulacja pełnej kampanii

5 min czytania
W tej lekcji6

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.

Kod do tej lekcji: src/e2e-testing.spec.ts
1// E2E Testing - Symulacja Pelnej Wyprawy
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { Controller, Get, Post, Body, Param, Module } from '@nestjs/common';
6import { Injectable } from '@nestjs/common';
7
8// Serwis legionow
9@Injectable()
10class LegionService {
11  private legions = [
12    { id: 1, name: 'Legio X', province: 'Gallia', soldiers: 5000 },
13  ];
14
15  findAll() { return this.legions; }
16  findOne(id: number) { return this.legions.find(l => l.id === id); }
17  create(data: any) {
18    const legion = { id: this.legions.length + 1, ...data };
19    this.legions.push(legion);
20    return legion;
21  }
22}
23
24// Kontroler
25@Controller('legions')
26class LegionController {
27  constructor(private service: LegionService) {}
28
29  @Get()
30  findAll() { return this.service.findAll(); }
31
32  @Get(':id')
33  findOne(@Param('id') id: string) {
34    return this.service.findOne(parseInt(id));
35  }
36
37  @Post()
38  create(@Body() data: any) {
39    return this.service.create(data);
40  }
41}
42
43// Modul
44@Module({
45  controllers: [LegionController],
46  providers: [LegionService],
47})
48class LegionModule {}
49
50// Testy E2E
51describe('Legion E2E Tests', () => {
52  let app: INestApplication;
53
54  beforeAll(async () => {
55    const moduleFixture: TestingModule = await Test.createTestingModule({
56      imports: [LegionModule],
57    }).compile();
58
59    app = moduleFixture.createNestApplication();
60    app.useGlobalPipes(new ValidationPipe());
61    await app.init();
62  });
63
64  afterAll(async () => {
65    await app.close();
66  });
67
68  it('GET /legions - should return all legions', () => {
69    return request(app.getHttpServer())
70      .get('/legions')
71      .expect(200)
72      .expect((res) => {
73        expect(Array.isArray(res.body)).toBe(true);
74        expect(res.body.length).toBeGreaterThan(0);
75      });
76  });
77
78  it('POST /legions - should create a legion', () => {
79    return request(app.getHttpServer())
80      .post('/legions')
81      .send({ name: 'Legio XIV', province: 'Pannonia', soldiers: 4500 })
82      .expect(201)
83      .expect((res) => {
84        expect(res.body.name).toBe('Legio XIV');
85        expect(res.body.id).toBeDefined();
86      });
87  });
88
89  it('GET /legions/:id - should return single legion', () => {
90    return request(app.getHttpServer())
91      .get('/legions/1')
92      .expect(200)
93      .expect((res) => {
94        expect(res.body.name).toBe('Legio X');
95      });
96  });
97});
98

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Przy testowaniu kontrolera NestJS serwisy powinny być:

  2. 2. expect(result).toEqual(expected) sprawdza:

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Przetestuj endpoint GET kontrolera legionów

  • Układanie w poziomie

    Ułóż asercję sprawdzającą wywołanie mocka z argumentami:

  • Edytor kodu

    Stwórz test E2E dla endpointu GET /legions

  • Układanie w pionie

    Ułóż kroki testu E2E z supertest w poprawnej kolejności:

  • Układanie w pionie

    Uporządkuj kroki przygotowania aplikacji do testu E2E:

Przydatne artykuły