Kurs NestJS · Moduł 7: Testowanie

Performance Testing - próby wytrzymałościowe

5 min czytania
W tej lekcji5

Legion sprawdził już, czy machiny działają. Zostało pytanie inne: ile wytrzymają? Katapulta, która oddaje jeden strzał, jest bezużyteczna przy oblężeniu trwającym tydzień. Testy wydajnościowe mierzą właśnie to - nie czy kod działa, ale jak długo i pod jakim naporem.

Takie próby są jednak wyjątkowo wrażliwe na bałagan. Test, który zostawia po sobie tysiąc rekordów w bazie, zafałszuje wynik następnego. Dlatego zanim zmierzymy wytrzymałość machin, ułożymy plac prób w porządku - i ten porządek przyda Ci się w każdym teście, nie tylko wydajnościowym.

Gdzie mieszka test

Zacznijmy od nazwy pliku, bo tu obowiązuje konwencja, której trzyma się cały ekosystem NestJS:

1legions.service.spec.ts

Czyta się ją od lewej: co testujemy (legions), jakiego rodzaju to element (service), że to plik testowy (spec), i wreszcie rozszerzenie (ts). Testy E2E dostają zamiast tego .e2e-spec.ts.

Ta konwencja nie jest ozdobnikiem - Jest domyślnie wyszukuje pliki po tym wzorcu. Plik nazwany testLegions.ts po prostu nigdy się nie uruchomi, a Ty będziesz się zastanawiać, czemu warta w CI świeci na zielono. Plik trzymamy obok testowanego kodu, dzięki czemu widać na pierwszy rzut oka, co ma testy, a co nie.

Wzorzec AAA - trzy takty jednej próby

Blok describe grupujący testy jednego elementu nazywamy suite - zestawem prób. Wewnątrz niego każdy test układa się w ten sam rytm, znany jako AAA:

1it('obsługuje 100 zapytań poniżej 2 sekund', async () => {
2  // Arrange - przygotuj dane i atrapy
3  const legions = Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Legio ${i}` }));
4  jest.spyOn(repo, 'find').mockResolvedValue(legions);
5
6  // Act - wywołaj testowaną metodę
7  const start = Date.now();
8  await Promise.all(Array.from({ length: 100 }, () => service.findAll()));
9  const duration = Date.now() - start;
10
11  // Assert - sprawdź wynik
12  expect(duration).toBeLessThan(2000);
13});

Trzy takty odpowiadają trzem słowom. Arrange przygotowuje scenę: dane, atrapy, stan początkowy. Act to jedno wywołanie - to, co właśnie badamy. Assert sprawdza wynik jedną lub kilkoma asercjami.

Wartość tego podziału jest praktyczna: gdy test padnie, od razu wiesz, w którym takcie szukać. A gdy w sekcji Act znajdziesz więcej niż jedno wywołanie, to znak, że test bada dwie rzeczy naraz i warto go rozdzielić.

Do trzech taktów dochodzi czwarty, o którym łatwo zapomnieć: Cleanup - posprzątanie po teście, w żargonie nazywane teardown. Nie zapisujemy go zwykle w ciele testu, tylko w osobnym haku, i to właśnie on decyduje o rzetelności prób wydajnościowych.

Haki - kiedy przygotować, kiedy posprzątać

Jest daje cztery haki, a ich wybór sprowadza się do jednego pytania: czy to ma się dziać raz, czy przy każdym teście?

1describe('LegionsService - wydajność', () => {
2  let service: LegionsService;
3  let module: TestingModule;
4
5  beforeAll(async () => {
6    module = await Test.createTestingModule({
7      providers: [LegionsService, { provide: getRepositoryToken(Legion), useValue: repo }],
8    }).compile();
9
10    service = module.get(LegionsService);
11  });
12
13  afterEach(() => {
14    jest.clearAllMocks();
15  });
16
17  afterAll(async () => {
18    await module.close();
19  });
20});

beforeAll uruchamia się raz przed wszystkimi testami w bloku - tu budujemy moduł testowy, bo to operacja kosztowna i nie ma powodu powtarzać jej sto razy. beforeEach działałby przed każdym testem; tam trafia to, co musi być świeże, na przykład czyszczenie tabeli.

Po drugiej stronie afterEach sprząta po każdym teście - jak tutaj zerowanie atrap - a afterAll zamyka to, co otworzył beforeAll: połączenia, moduł, pliki.

Zasada jest symetryczna i warto ją zapamiętać: co otworzył beforeAll, zamyka afterAll; co przygotował beforeEach, sprząta afterEach. Niedomknięte połączenie sprawi, że Jest zawiesi się po ostatnim teście, wypisując, że coś jeszcze działa.

Przypadki brzegowe (edge cases) - gdzie machiny pękają

Test przy typowym obciążeniu mówi niewiele. Najciekawsze jest to, co dzieje się na krańcach, a dla prób wytrzymałościowych krańce są trzy: zero, jeden i bardzo dużo.

1describe('findAll pod obciążeniem', () => {
2  it('zwraca pustą tablicę, gdy legionów brak', async () => {
3    jest.spyOn(repo, 'find').mockResolvedValue([]);
4
5    await expect(service.findAll()).resolves.toEqual([]);
6  });
7
8  it('nie przekracza 5 sekund przy 10 000 legionów', async () => {
9    jest.spyOn(repo, 'find').mockResolvedValue(makeLegions(10_000));
10
11    const start = Date.now();
12    await service.findAll();
13
14    expect(Date.now() - start).toBeLessThan(5000);
15  });
16});

Pierwszy test pilnuje pustki - tam, gdzie kod lubi rzucić błędem przy legions[0]. Drugi to spike: nagły, wielokrotnie większy ładunek, sprawdzający, czy metoda skaluje się rozsądnie, czy zaczyna dławić się przy każdym rekordzie z osobna.

Jedna uwaga o progach czasowych: ustawiaj je z zapasem. Maszyna w CI bywa wolniejsza od Twojej, a test, który pada losowo raz na dziesięć przebiegów, zostanie wyłączony przez pierwszą osobę, której zablokuje wdrożenie - i słusznie. Lepszy próg luźny, ale wiarygodny, niż ostry i ignorowany.

Podsumowanie

Plac prób uporządkowany, machiny zmierzone:

  • testy wydajnościowe pytają nie czy kod działa, ale jak długo i pod jakim naporem,
  • nazwa pliku to konwencja, po której Jest wyszukuje testy: nazwa.service.spec.ts, a dla E2E .e2e-spec.ts,
  • wzorzec AAA: Arrange przygotowuje dane i atrapy, Act wywołuje badaną metodę, Assert sprawdza wynik, a Cleanup sprząta,
  • więcej niż jedno wywołanie w sekcji Act znaczy, że test bada dwie rzeczy naraz,
  • suite to blok describe; przygotowanie nazywamy setup, sprzątanie - teardown,
  • beforeAll i afterAll obsługują to, co kosztowne i jednorazowe; beforeEach i afterEach - to, co musi być świeże przy każdym teście,
  • co otworzył beforeAll, zamyka afterAll - niedomknięte połączenie zawiesza Jest po ostatnim teście,
  • przypadki brzegowe dla wytrzymałości to zero, jeden i bardzo dużo; ostatni z nich to spike,
  • progi czasowe ustawiaj z zapasem - test padający losowo zostanie wyłączony i przestanie chronić cokolwiek.

W następnej lekcji te próby przestaną zależeć od Twojej pamięci - wpuścimy je do automatycznej warty w CI. A na razie zapamiętaj: wzorzec AAA to trzy takty jednej próby, a haki pilnują, żeby każda zaczynała się na czystym placu.

Kod do tej lekcji: src/performance-testing.ts
1// Performance Testing - Proby Wytrzymalosciowe Fortu
2
3// Narzedzia do testowania wydajnosci w NestJS
4
5// 1. Mierzenie czasu wykonania
6class PerformanceTester {
7  static async measureExecution<T>(
8    fn: () => Promise<T>,
9    label: string
10  ): Promise<{ result: T; duration: number }> {
11    const start = performance.now();
12    const result = await fn();
13    const duration = performance.now() - start;
14    console.log(`[PERF] ${label}: ${duration.toFixed(2)}ms`);
15    return { result, duration };
16  }
17
18  // Testuj obciazenie - symuluj wielu uzytkownikow
19  static async loadTest(
20    fn: () => Promise<any>,
21    concurrency: number,
22    iterations: number
23  ) {
24    const results: number[] = [];
25
26    for (let i = 0; i < iterations; i++) {
27      const promises = Array.from({ length: concurrency }, () => {
28        const start = performance.now();
29        return fn().then(() => performance.now() - start);
30      });
31      const times = await Promise.all(promises);
32      results.push(...times);
33    }
34
35    const sorted = [...results].sort((a, b) => a - b);
36    const avg = results.reduce((a, b) => a + b, 0) / results.length;
37
38    return {
39      totalRequests: results.length,
40      avgResponseTime: avg.toFixed(2) + 'ms',
41      maxResponseTime: Math.max(...results).toFixed(2) + 'ms',
42      minResponseTime: Math.min(...results).toFixed(2) + 'ms',
43      p95: sorted[Math.floor(results.length * 0.95)].toFixed(2) + 'ms',
44    };
45  }
46}
47
48// 2. Serwis z roznymi zlozonosci operacji
49class LegionQueryService {
50  private legions: Array<{ id: number; name: string; soldiers: number }>;
51
52  constructor() {
53    this.legions = Array.from({ length: 10000 }, (_, i) => ({
54      id: i + 1,
55      name: `Legio ${i + 1}`,
56      soldiers: Math.floor(Math.random() * 10000),
57    }));
58  }
59
60  findByName(name: string) {
61    return this.legions.find(l => l.name === name); // O(n)
62  }
63
64  findById(id: number) {
65    return this.legions[id - 1]; // O(1)
66  }
67
68  getTopLegions(count: number) {
69    return [...this.legions]
70      .sort((a, b) => b.soldiers - a.soldiers)
71      .slice(0, count); // O(n log n)
72  }
73}
74
75// 3. Benchmark
76async function runBenchmark() {
77  const service = new LegionQueryService();
78
79  console.log('=== BENCHMARK: Wyszukiwanie Legionow ===');
80
81  await PerformanceTester.measureExecution(
82    async () => service.findById(5000),
83    'findById (O(1))'
84  );
85
86  await PerformanceTester.measureExecution(
87    async () => service.findByName('Legio 5000'),
88    'findByName (O(n))'
89  );
90
91  await PerformanceTester.measureExecution(
92    async () => service.getTopLegions(10),
93    'getTopLegions (O(n log n))'
94  );
95}
96

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz pełny suite testowy z setup, teardown i edge cases

  • Układanie w poziomie

    Ułóż poprawną konwencję nazewnictwa pliku testowego:

  • Klikanie w kolejności

    Ułóż wzorzec AAA (Arrange-Act-Assert) testu:

Przydatne artykuły