Kurs NestJS · Moduł 7: Testowanie

Test Automation - automatyczne kontrole jakości

5 min czytania
W tej lekcji6

Masz komplet testów: jednostkowe, integracyjne, E2E, z atrapami i raportem pokrycia. Ale uruchamiasz je ręcznie. Wystarczy jeden pośpieszny wieczór, żeby ktoś wypchnął zmianę bez sprawdzenia - i cały ten arsenał okaże się bezużyteczny.

Legion nie polega na pamięci wartowników. Ustawia warty stałe: kontrola odbywa się sama, o wyznaczonej porze, niezależnie od tego, kto akurat pełni służbę. Tym właśnie jest automatyzacja testów - przeniesieniem odpowiedzialności z człowieka na maszynę.

Warta stała - testy przy każdej zmianie

Najprostsza warta uruchamia testy przy każdym wypchnięciu kodu:

1# .github/workflows/ci.yml
2name: CI
3on: [push, pull_request]
4
5jobs:
6  test:
7    runs-on: ubuntu-latest
8    steps:
9      - uses: actions/checkout@v4
10      - run: npm ci
11      - run: npm run test
12      - run: npm run test:e2e

Czyta się to jak rozkaz dzienny. on: [push, pull_request] wyznacza moment kontroli. npm ci instaluje zależności ściśle według package-lock.json - w przeciwieństwie do npm install niczego nie aktualizuje, więc warta sprawdza dokładnie te wersje, które zapisałeś. Potem lecą testy.

Kluczowe jest to, czego w tym pliku nie widać: każde polecenie musi zwrócić zero. Jeśli npm run test zakończy się błędem, warta podnosi alarm i zmiana nie wejdzie do gałęzi głównej. Cała moc automatyzacji sprowadza się do tego jednego kodu wyjścia.

Zielona warta, która kłamie

I tu dochodzimy do najważniejszej rzeczy w tej lekcji. Zautomatyzowany test jest wart tyle, ile jego rzetelność - a test asynchroniczny potrafi przechodzić, nie sprawdzając niczego.

Spójrz na te dwie wersje:

1// ŹLE - test kończy się, zanim obietnica się rozstrzygnie
2it('znajduje legionistę', () => {
3  service.findById(1);
4});
5
6// DOBRZE - test czeka na wynik
7it('znajduje legionistę', async () => {
8  const legionary = await service.findById(1);
9  expect(legionary.name).toBe('Marek');
10});

W pierwszej wersji funkcja testowa kończy się natychmiast po wywołaniu metody. Jest uznaje test za zaliczony, bo nic nie rzuciło wyjątku w tym momencie - a metoda dopiero zaczyna pracę. Gdyby zwróciła złe dane albo padła sekundę później, nikt się nie dowie.

Reguła jest krótka i bezwyjątkowa: test dotykający kodu asynchronicznego musi być async, a każde wywołanie zwracające obietnicę - poprzedzone await. Zapomniany await to najczęstsza przyczyna zielonych pipeline'ów nad zepsutym kodem.

Sprawdzanie wyjątków - wariant synchroniczny

Testujemy nie tylko powodzenia. Gdy metoda ma rzucić wyjątek, potrzebujemy asercji, która to potwierdzi:

1it('odrzuca ujemny żołd', () => {
2  expect(() => service.validatePay(-100)).toThrow();
3});

Zwróć uwagę na kształt argumentu: do expect przekazujemy funkcję, a nie wynik jej wywołania. Gdybyśmy napisali expect(service.validatePay(-100)), wyjątek poleciałby natychmiast, poza kontrolą Jest, i test padłby zamiast przejść. Owinięcie w () => ... daje Jest możliwość wywołania metody samemu i przechwycenia tego, co z niej wypadnie.

toThrow() bez argumentu akceptuje dowolny wyjątek. Możesz zawęzić: toThrow(BadRequestException) sprawdzi typ, a toThrow('Żołd nie może być ujemny') - treść komunikatu.

Sprawdzanie wyjątków - wariant asynchroniczny

Metoda asynchroniczna nie rzuca wyjątku - zwraca odrzuconą obietnicę. Dlatego toThrow() z poprzedniej sekcji tu nie zadziała; potrzebna jest przystawka rejects:

1it('rzuca NotFoundException dla nieznanego id', async () => {
2  jest.spyOn(repo, 'findOne').mockResolvedValue(null);
3
4  await expect(service.findById(999)).rejects.toThrow(NotFoundException);
5});

Prześledźmy tę asercję po kolei, bo składa się z czterech elementów w ustalonej kolejności. await expect( otwiera oczekiwanie - i to await na początku jest tym, o czym zapomina się najczęściej; bez niego test skończy się przed rozstrzygnięciem obietnicy i znów przejdzie fałszywie. Dalej service.findById(999) - tutaj nie owijamy wywołania w funkcję, bo obietnica jest zwykłą wartością, którą można przekazać. Potem .rejects przełącza asercję na odrzucenie, a .toThrow(NotFoundException) sprawdza typ.

Kolejność pracy przy takim teście jest zawsze ta sama: ustaw atrapę tak, by wywołała błąd, wywołaj metodę, użyj rejects.toThrow(), a na końcu doprecyzuj typ i komunikat. Tutaj atrapa repozytorium zwraca null, co zmusza serwis do rzucenia NotFoundException - sprawdzamy więc obsługę błędu, nie sam błąd bazy.

Bramka lokalna - zanim kod wyjdzie z obozu

Warta w CI wyłapuje wszystko, ale odpowiada po kilku minutach. Szybszą kontrolę stawiamy na własnej maszynie:

1# .husky/pre-commit
2npm run lint
3npm run test -- --onlyChanged

Husky podpina ten skrypt pod git commit - gdy któreś polecenie zwróci błąd, commit się nie wykona. Flaga --onlyChanged każe Jest uruchomić wyłącznie testy związane ze zmienionymi plikami, więc bramka trwa sekundy, a nie minuty.

Zauważ podział ról: hak lokalny jest szybki i wybiórczy, warta w CI - wolna i kompletna. Hak ma łapać oczywiste potknięcia, zanim zajmą czyjś czas; nie zastępuje pełnego przebiegu. I to polecam: nie wsadzaj do haka wszystkich testów, bo pierwsze, czego nauczy się zespół, to --no-verify.

Podsumowanie

Warty rozstawione, kontrola dzieje się sama:

  • automatyzacja przenosi uruchamianie testów z człowieka na maszynę - decyduje kod wyjścia polecenia,
  • on: [push, pull_request] wyznacza moment kontroli, npm ci instaluje dokładnie wersje z package-lock.json,
  • test dotykający kodu asynchronicznego musi być async i używać await - bez tego przechodzi, nie sprawdzając niczego,
  • wyjątek synchroniczny: expect(() => metoda()).toThrow() - do expect idzie funkcja, nie jej wynik,
  • toThrow() przyjmuje typ wyjątku lub fragment komunikatu, gdy chcesz zawęzić sprawdzenie,
  • odrzucona obietnica: await expect(metoda()).rejects.toThrow(NotFoundException) - tu przekazujemy samą obietnicę, bez owijania w funkcję,
  • kolejność: ustaw atrapę na błąd, wywołaj metodę, rejects.toThrow(), zweryfikuj typ i komunikat,
  • hak pre-commit z --onlyChanged daje szybką bramkę lokalną; pełny przebieg zostaw dla CI.

W następnej lekcji zmierzysz się z projektem - kompleksowym otestowaniem systemu zarządzania flotą. A na razie zapamiętaj: warta stała jest warta tyle, ile rzetelność testów, które uruchamia - a test bez await melduje spokój, choć nikt nie sprawdzał bram.

Kod do tej lekcji: src/test-automation.ts
1// Test Automation - Automatyczne Kontrole Jakosci
2
3// 1. Konfiguracja Jest w package.json
4const jestConfig = {
5  moduleFileExtensions: ['js', 'json', 'ts'],
6  rootDir: 'src',
7  testRegex: '.*\\.spec\\.ts$',
8  transform: { '^.+\\.(t|j)s$': 'ts-jest' },
9  collectCoverageFrom: ['**/*.(t|j)s'],
10  coverageDirectory: '../coverage',
11  testEnvironment: 'node',
12  coverageThreshold: {
13    global: {
14      branches: 80,
15      functions: 80,
16      lines: 80,
17      statements: 80,
18    },
19  },
20};
21
22// 2. Pre-commit hooks z Husky
23const huskyConfig = {
24  hooks: {
25    'pre-commit': 'npm run lint && npm run test',
26    'pre-push': 'npm run test:cov && npm run test:e2e',
27  },
28};
29
30// 3. GitHub Actions workflow
31const ciYaml = `
32name: Roman Empire CI
33on: [push, pull_request]
34jobs:
35  test:
36    runs-on: ubuntu-latest
37    strategy:
38      matrix:
39        node-version: [18.x, 20.x]
40    steps:
41      - uses: actions/checkout@v4
42      - uses: actions/setup-node@v4
43        with:
44          node-version: \${{ matrix.node-version }}
45      - run: npm ci
46      - run: npm run lint
47      - run: npm run test:cov
48      - run: npm run test:e2e
49`;
50
51// 4. Test Reporter
52class TestReporter {
53  private results: Array<{
54    suite: string;
55    test: string;
56    passed: boolean;
57    duration: number;
58  }> = [];
59
60  addResult(
61    suite: string, test: string,
62    passed: boolean, duration: number
63  ) {
64    this.results.push({ suite, test, passed, duration });
65  }
66
67  generateReport() {
68    const total = this.results.length;
69    const passed = this.results.filter(r => r.passed).length;
70    const failed = total - passed;
71    const avgDuration = this.results.reduce(
72      (sum, r) => sum + r.duration, 0
73    ) / total;
74
75    return {
76      summary: {
77        total,
78        passed,
79        failed,
80        passRate: `${((passed / total) * 100).toFixed(1)}%`,
81        avgDuration: `${avgDuration.toFixed(2)}ms`,
82      },
83      failures: this.results
84        .filter(r => !r.passed)
85        .map(r => `${r.suite} > ${r.test}`),
86    };
87  }
88}
89
90// 5. Przyklad uzycia
91const reporter = new TestReporter();
92reporter.addResult('LegionService', 'findAll', true, 5.2);
93reporter.addResult('LegionService', 'create', true, 8.1);
94reporter.addResult('ProvinceCtrl', 'GET /provinces', true, 12.3);
95reporter.addResult('AuthGuard', 'block unauthorized', true, 3.7);
96
97console.log(JSON.stringify(reporter.generateReport(), null, 2));
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. Aby przetestować asynchroniczną metodę w Jest, test powinien:

  2. 2. Aby sprawdzić czy metoda rzuca wyjątek, używamy:

Zadania praktyczne w grze

  • Edytor kodu

    Przetestuj scenariusz gdy serwis rzuca NotFoundException

  • Układanie w pionie

    Uporządkuj kroki testowania asynchronicznego wyjątku:

  • Klikanie w kolejności

    Ułóż asercję testującą rejected Promise:

Przydatne artykuły