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ę.
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.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ć
, a każde wywołanie zwracające obietnicę - poprzedzone async
. Zapomniany await
await to najczęstsza przyczyna zielonych pipeline'ów nad zepsutym kodem.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.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.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, @name: nie wsadzaj do haka wszystkich testów, bo pierwsze, czego nauczy się zespół, to
--no-verify.Warty rozstawione, kontrola dzieje się sama:
on: [push, pull_request] wyznacza moment kontroli, npm ci instaluje dokładnie wersje z package-lock.json,async i używać await - bez tego przechodzi, nie sprawdzając niczego,expect(() => metoda()).toThrow() - do expect idzie funkcja, nie jej wynik,toThrow() przyjmuje typ wyjątku lub fragment komunikatu, gdy chcesz zawęzić sprawdzenie,await expect(metoda()).rejects.toThrow(NotFoundException) - tu przekazujemy samą obietnicę, bez owijania w funkcję,rejects.toThrow(), zweryfikuj typ i komunikat,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.