Kurs NestJS · Moduł 7: Testowanie
Test Automation - automatyczne kontrole jakości
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:e2eCzyta 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 -- --onlyChangedHusky 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 ciinstaluje dokładnie wersje zpackage-lock.json,- test dotykający kodu asynchronicznego musi być
asynci używaćawait- bez tego przechodzi, nie sprawdzając niczego, - wyjątek synchroniczny:
expect(() => metoda()).toThrow()- doexpectidzie 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-commitz--onlyChangeddaje 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));
98Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Aby przetestować asynchroniczną metodę w Jest, test powinien:
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: