Kurs NestJS · Moduł 7: Testowanie
E2E Testing - symulacja pełnej kampanii
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.jsonPlik 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' }); // padaRóż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 wtest/jest-e2e.json, uruchamia jenpm run test:e2e, - cztery kroki przygotowania:
createTestingModule({ imports: [AppModule] }),.compile(),createNestApplication(),await app.init(), - globalne elementy z
main.tsnie wchodzą automatycznie -ValidationPipei 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,
toEqualporównuje głęboko strukturę i wartości - to wybór dla obiektów;toBesprawdza 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()wafterAlljest 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});
98Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Przy testowaniu kontrolera NestJS serwisy powinny być:
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: