Soft

Blog

Jak testować architekturę, błędy i procesy użytkownika w Next.js

Niezawodne testowanie Next.js nie ogranicza się do snapshotów komponentów i poprawnych odpowiedzi API. Strategia produkcyjna musi chronić granice architektury, obsługę awarii, zachowanie części serwerowej i klienckiej oraz kompletne procesy użytkowników.

By Damian Ostraszewski

Przechodzący test komponentu nie dowodzi, że aplikacja działa

Aplikacja Next.js może mieć setki przechodzących testów jednostkowych, a mimo to zawieść w procesie, który ma znaczenie dla klienta.

Przycisk może renderować się poprawnie, podczas gdy:

  • serwer zwraca dane w nieoczekiwanym kształcie,
  • sesja wygasa podczas wysyłania formularza,
  • trasa zgłasza wyjątek w trakcie renderowania,
  • po mutacji w cache pozostają nieaktualne dane,
  • żądanie jest wysyłane dwukrotnie,
  • przekierowanie gubi pierwotny adres docelowy,
  • error boundary ukrywa przydatne możliwości odzyskania działania,
  • API dostępne tylko w przeglądarce zawodzi podczas renderowania po stronie serwera,
  • stan ładowania nigdy się nie kończy,
  • aktualizacja optymistyczna pokazuje dane, które nie zostały zapisane.

Strategia testowania musi więc chronić więcej niż komponenty.

Produkcyjna aplikacja Next.js powinna być testowana w trzech powiązanych wymiarach:

Architektura
→ Czy odpowiedzialności i kierunki zależności są właściwe?

Awarie
→ Czy aplikacja zachowuje się bezpiecznie, gdy zawodzą jej zależności?

Procesy użytkownika
→ Czy użytkownik może osiągnąć ważny cel przez rzeczywisty interfejs?

Testowanie tych wymiarów oddzielnie jest użyteczne.

Dopiero testowanie ich wzajemnych zależności rzeczywiście chroni aplikację.

Zacznij od zachowania, od którego zależy biznes

Zespoły często zaczynają od pytania:

Jaki procent pokrycia testami powinniśmy osiągnąć?

Lepsze pytanie brzmi:

Które awarie uniemożliwiłyby użytkownikowi ukończenie ważnego zadania?

Rozważmy proces składania zamówienia w sklepie internetowym:

Otwórz produkt
→ dodaj produkt do koszyka
→ otwórz checkout
→ wprowadź dane dostawy
→ złóż zamówienie
→ autoryzuj płatność
→ wyświetl potwierdzenie

Dla biznesu nie ma znaczenia, czy każdy komponent React ma osobny snapshot.

Znaczenie ma to, czy:

  • właściwy produkt trafia do koszyka,
  • przeglądarka nie może zmienić cen,
  • zamówienie nie zostaje złożone dwukrotnie,
  • po błędzie płatności można kontynuować,
  • wygaśnięcie sesji nie usuwa wszystkich wprowadzonych danych,
  • udana płatność tworzy dokładnie jedno zamówienie,
  • potwierdzenie pokazuje właściwy numer zamówienia.

To proces powinien wyznaczać strategię testowania.

Użyteczna mapa testów aplikacji Next.js

Praktyczna architektura testów może wyglądać następująco:

Kontrole statyczne
    │
    ├── TypeScript
    ├── linting
    └── reguły importów i architektury
    │
    ▼
Testy domeny i warstwy aplikacyjnej
    │
    ├── obliczenia biznesowe
    ├── przejścia między stanami
    └── orkiestracja przypadków użycia
    │
    ▼
Testy komponentów
    │
    ├── interakcje
    ├── walidacja
    ├── ładowanie
    └── błędy możliwe do obsłużenia
    │
    ▼
Testy kontraktowe i integracyjne
    │
    ├── odpowiedzi backendu
    ├── uwierzytelnianie
    ├── route handlers
    └── unieważnianie cache
    │
    ▼
Testy procesów w przeglądarce
    │
    ├── nawigacja
    ├── kompletne formularze
    ├── przekierowania
    └── krytyczne ścieżki
    │
    ▼
Weryfikacja produkcyjna
        ├── logi
        ├── metryki
        ├── trace’y
        └── testy syntetyczne

Każda warstwa odpowiada na inne pytanie.

Używanie wyłącznie testów przeglądarkowych sprawia, że diagnozowanie awarii jest powolne.

Używanie wyłącznie testów jednostkowych pozostawia błędy integracyjne bez ochrony.

Pipeline CI powinien przy każdej zmianie uruchamiać szybkie, deterministyczne kontrole, izolować dane testów przeglądarkowych i traktować wolniejsze procesy między usługami jako zależne od ryzyka bramki wydania, a nie opcjonalną diagnostykę.

Testuj architekturę, zanim stanie się trudna do zmiany

Testy architektury weryfikują, od czego kod może zależeć.

Utrzymywalny projekt Next.js może stosować granice podobne do tych:

app
→ kompozycja tras i integracja z frameworkiem

features
→ przypadki użycia widoczne dla użytkownika

domain
→ typy i reguły biznesowe

application
→ orkiestracja i porty

infrastructure
→ klienty API, storage i adaptery dostawców

ui
→ współdzielone komponenty prezentacyjne

Dokładne nazwy katalogów są mniej ważne niż kierunek zależności.

Funkcja nie powinna wymagać, aby komponent React znał:

  • surowe struktury bazy danych,
  • obiekty odpowiedzi SDK dostawcy,
  • wewnętrzną strukturę ciasteczek uwierzytelniających,
  • kody błędów operatora płatności,
  • szczegóły implementacji cache.

Koncepcyjna reguła zależności wygląda tak:

UI
→ kontrakt warstwy aplikacyjnej
→ kontrakt domenowy
← adapter infrastruktury

Infrastruktura implementuje kontrakt.

Warstwa aplikacyjna nie zależy od dostawcy.

Przykład: odizoluj zależność checkoutu

Zamiast wywoływać zewnętrzne API bezpośrednio z komponentu:

export function CheckoutForm(): JSX.Element {
    async function submit(): Promise {
        await fetch('/api/orders', {
            method: 'POST',
            body: JSON.stringify(formValues),
        });
    }

    return {/* ... */};
}

zdefiniuj kontrakt należący do projektu:

export type CheckoutInput = Readonly;

export type CheckoutResult =
    | Readonly
    | Readonly
    | Readonly;

export interface CheckoutGateway {
    submit(input: CheckoutInput): Promise;
}

Adapter infrastruktury jest właścicielem szczegółów HTTP:

export class HttpCheckoutGateway implements CheckoutGateway {
    public constructor(
        private readonly baseUrl: string,
        private readonly fetcher: typeof fetch,
    ) {}

    public async submit(
        input: CheckoutInput,
    ): Promise {
        const response = await this.fetcher(
            `${this.baseUrl}/orders`,
            {
                method: 'POST',
                headers: {
                    'content-type': 'application/json',
                    'idempotency-key': input.idempotencyKey,
                },
                body: JSON.stringify(input),
            },
        );

        if (!response.ok) {
            throw await mapCheckoutFailure(response);
        }

        return validateCheckoutResult(await response.json());
    }
}

Testy mogą teraz zastąpić gateway bez globalnego podmieniania komunikacji sieciowej przeglądarki.

Adapter może mieć skupione testy kontraktowe.

Komponent może mieć testy zachowania.

Pełny proces nadal może zostać zabezpieczony testem przeglądarkowym.

Testy architektury powinny wymuszać rzeczywiste granice

W zależności od narzędzi używanych już w projekcie reguły architektury można sprawdzać przez:

  • ograniczenia importów w lintingu,
  • analizę zależności,
  • referencje projektów TypeScript,
  • granice pakietów,
  • ukierunkowane testy kodu źródłowego.

Użyteczne reguły obejmują:

Kod domenowy nie może importować React ani Next.js.
Przypadki użycia warstwy aplikacyjnej nie mogą importować SDK dostawców.
Komponenty UI nie mogą bezpośrednio importować implementacji infrastruktury.
Moduł funkcjonalny nie może sięgać do wewnętrznych plików innego modułu.
Moduły server-only nie mogą trafiać do bundli klienckich.
Pliki tras powinny składać zależności, a nie zawierać kompletne procesy biznesowe.

Nie twórz reguł tylko dlatego, że brzmią elegancko.

Każda reguła powinna zapobiegać rzeczywistej klasie błędów.

Testuj obserwowalne zachowanie, a nie wewnętrzną implementację

Kruchy test komponentu sprawdza:

Komponent wywołał setState trzy razy.

Użyteczny test sprawdza:

Przycisk wysyłania jest niedostępny w trakcie żądania.
Nieudane żądanie pokazuje błąd, po którym można kontynuować.
Wartości wprowadzone przez użytkownika pozostają w formularzu.
Udany wynik prowadzi do strony potwierdzenia.

Testy powinny przetrwać wewnętrzną refaktoryzację, jeśli zachowanie widoczne dla użytkownika się nie zmieniło.

Unikaj sprawdzania:

  • wywołań prywatnych helperów,
  • szczegółów implementacji hooków,
  • dokładnej głębokości drzewa komponentów,
  • pełnych snapshotów HTML złożonych ekranów,
  • klas generowanych przez framework.

Sprawdzaj rezultaty i stabilne role dostępności.

Testuj odpowiedzialności serwera i klienta oddzielnie

Aplikacje Next.js często łączą dane renderowane na serwerze z interakcjami po stronie klienta.

Użyteczny podział wygląda tak:

Odpowiedzialność serwera
→ uwierzytelnianie
→ początkowe ładowanie danych
→ autoryzacja
→ stabilna kompozycja strony

Odpowiedzialność klienta
→ natychmiastowa interakcja
→ lokalna walidacja
→ stan oczekiwania
→ ponowienie
→ progresywne aktualizacje

Test serwera powinien potwierdzać, że nieuprawnieni użytkownicy nie otrzymują chronionych danych.

Test komponentu powinien potwierdzać, że użytkownik widzi użyteczny stan, gdy mutacja po stronie klienta kończy się błędem.

Test przeglądarkowy powinien sprawdzać pełne zachowanie.

Nie wymuszaj testowania każdego warunku kosztownym testem przeglądarkowym.

Przypadki awarii muszą być częścią projektu

Większość testów zaczyna się od poprawnych odpowiedzi:

gateway.submit.mockResolvedValue({
    status: 'completed',
    orderId: 'order-123',
    confirmationNumber: 'ORD-2026-123',
});

Incydenty produkcyjne zwykle zaczynają się gdzie indziej:

API przekroczyło limit czasu.
Użytkownik kliknął dwukrotnie.
Odpowiedź była poprawnym JSON-em, ale miała nieprawidłowy kształt.
Sesja wygasła.
Koszyk się zmienił.
Dostawca płatności zaakceptował płatność, lecz przekierowanie nie zadziałało.
Cache pokazał stare dane.

Dla procesu należy przygotować jawną macierz awarii.

Przykładowa macierz awarii checkoutu

Awaria                           Oczekiwane zachowanie aplikacji
─────────────────────────────────────────────────────────────────────────────
Żądanie produktu zawodzi         Pokaż ponowienie bez awarii całej trasy
Koszyk jest pusty                Przekieruj albo pokaż czytelny pusty stan
Koszyk zmienił się na serwerze   Odśwież koszyk i poproś o potwierdzenie
Sesja wygasła                    Poproś o logowanie i zachowaj adres powrotu
Checkout przekracza czas         Pokaż stan niepewny; nie ponawiaj automatycznie
Płatność odrzucona               Zachowaj kontekst i pozwól wybrać inną metodę
Nieprawidłowy schemat odpowiedzi Pokaż bezpieczny błąd; zgłoś naruszenie kontraktu
Podwójne wysłanie                Użyj tego samego klucza i nie duplikuj zamówienia
Żądanie potwierdzenia zawodzi    Pozwól odzyskać potwierdzenie po numerze zamówienia
Nieaktualny koszyk w cache       Unieważnij lub odśwież po przyjętej mutacji

Każdy ważny wiersz powinien być zabezpieczony na najtańszej wiarygodnej warstwie testów.

Testuj ładowanie jako stan, a nie jako screenshot

Zachowanie podczas ładowania powinno odpowiadać na pytania:

  • Co nadal pozostaje dostępne?
  • Co jest wyłączone?
  • Czy użytkownik może przejść gdzie indziej?
  • Czy układ pozostaje stabilny?
  • Co dzieje się po wolnej odpowiedzi?
  • Czy istnieje limit czasu lub ścieżka ponowienia?
  • Czy ekran w dostępny sposób informuje o postępie?

Test komponentu może sprawdzać:

it('prevents duplicate checkout submission while pending', async () => {
    const deferred = createDeferred();
    const submit = vi.fn(() => deferred.promise);

    render();

    await userEvent.click(
        screen.getByRole('button', { name: /place order/i }),
    );

    expect(
        screen.getByRole('button', { name: /place order/i }),
    ).toBeDisabled();

    await userEvent.click(
        screen.getByRole('button', { name: /place order/i }),
    );

    expect(submit).toHaveBeenCalledTimes(1);

    deferred.resolve({
        status: 'completed',
        orderId: 'order-123',
        confirmationNumber: 'ORD-123',
    });
});

Dostosuj składnię do test runnera używanego w projekcie.

Przykład korzysta z React Testing Library i składni zgodnej z Vitest; w projekcie używającym Jest należy zachować te same asercje zachowania, zamiast zmieniać strategię tylko ze względu na runner.

Najważniejsza asercja mówi, że wolna odpowiedź nie powoduje ponownego wysłania zamówienia.

Jawnie testuj błędy, po których można kontynuować

Sam komunikat błędu nie wystarcza, jeżeli użytkownik nie może odzyskać możliwości działania.

Przykład:

it('keeps entered delivery data after a recoverable failure', async () => {
    const submit = vi
        .fn()
        .mockRejectedValueOnce(new NetworkUnavailable())
        .mockResolvedValueOnce({
            status: 'completed',
            orderId: 'order-123',
            confirmationNumber: 'ORD-123',
        });

    render();

    await userEvent.type(
        screen.getByLabelText(/delivery note/i),
        'Leave with reception',
    );

    await userEvent.click(
        screen.getByRole('button', { name: /place order/i }),
    );

    expect(
        await screen.findByRole('alert'),
    ).toHaveTextContent(/could not submit/i);

    expect(
        screen.getByLabelText(/delivery note/i),
    ).toHaveValue('Leave with reception');

    await userEvent.click(
        screen.getByRole('button', { name: /try again/i }),
    );

    expect(submit).toHaveBeenCalledTimes(2);
});

Ten test chroni wysiłek użytkownika, a nie tylko komunikat błędu.

Odróżniaj odrzucenia biznesowe od awarii technicznych

Poniższe wyniki nie są równoważne:

Dostawca płatności jest niedostępny
Koszyk zawiera niedostępny produkt
Użytkownik nie ma uprawnienia
Backend zwrócił nieprawidłowe dane

Stosuj jawne typy wyników i kategorie błędów.

Odrzucenie biznesowe można wyrenderować jako normalny wynik procesu:

if (result.status === 'rejected') {
    return showCheckoutCorrection(result.reason);
}

Awaria techniczna może wymagać:

  • ponowienia,
  • logowania,
  • metryk incydentu,
  • zachowania awaryjnego.

Nie zamieniaj każdego błędu w ogólne Something went wrong.

Ogólne komunikaty mogą być właściwe ze względów bezpieczeństwa, ale wewnętrzna klasyfikacja powinna pozostać precyzyjna.

Waliduj odpowiedzi backendu na granicy

Typy TypeScript nie walidują JSON-u w czasie wykonania.

Ten kod jest niebezpieczny:

const result = await response.json() as CheckoutResult;

Rzutowanie jedynie instruuje kompilator.

Nie sprawdza odpowiedzi.

Użyj przyjętej w projekcie walidacji w czasie wykonania:

const payload: unknown = await response.json();

return checkoutResultSchema.parse(payload);

albo równoważnego walidatora należącego do projektu.

Testuj:

  • brakujące właściwości,
  • nieprawidłowe wartości enumów,
  • nieprawidłowe liczby,
  • nieoczekiwane wartości null,
  • nieobsługiwane dodatkowe dane, gdy ma to znaczenie,
  • niezgodne wersje backendu.

Naruszenie kontraktu powinno zostać wykryte blisko adaptera, zamiast później powodować niezwiązany z nim błąd komponentu.

Testuj awarie na poziomie trasy

Trasa może zawieść, zanim zostanie wyrenderowany interaktywny komponent.

Przykłady:

  • błąd odczytu uwierzytelnienia,
  • niedostępna usługa backendowa,
  • nieprawidłowy parametr trasy,
  • brak rekordu,
  • odrzucenie autoryzacji,
  • przekroczenie czasu w data loaderze,
  • nieoczekiwany schemat odpowiedzi.

Testuj zachowanie trasy zgodnie z podejściem przyjętym w projekcie.

Sprawdź zamierzony rezultat:

Brak rekordu
→ widok not found

Nieuprawniony użytkownik
→ logowanie albo odmowa dostępu

Tymczasowa awaria backendu
→ błąd trasy z możliwością ponowienia

Nieprawidłowy parametr trasy
→ kontrolowana odpowiedź, nie nieobsłużony wyjątek

Korzystaj z obecnych w projekcie konwencji frameworka dotyczących błędów, ładowania i braku zasobu.

Nie twórz własnej infrastruktury błędów, jeśli aplikacja ma już ustalony wzorzec.

Error boundary powinien umożliwiać odzyskanie działania

Error boundary nie powinien wyświetlać wyłącznie:

Something went wrong.

Powinien odpowiadać na pytania:

  • Co zawiodło?
  • Czy awaria jest tymczasowa?
  • Czy operację można ponowić?
  • Czy nawigacja nadal działa?
  • Czy dane wprowadzone przez użytkownika zostały zachowane?
  • Czy ponowienie może zduplikować operację?
  • Czy błąd został bezpiecznie zgłoszony?

Użyteczny test poziomu trasy weryfikuje:

Pierwsze żądanie zgłasza wyjątek
→ pojawia się interfejs błędu
→ użytkownik wybiera ponowienie
→ drugie żądanie kończy się powodzeniem
→ pojawia się oczekiwana treść strony

Nie sprawdzaj wewnętrznych funkcji resetujących frameworka, jeśli to samo zachowanie można zweryfikować przez interfejs.

Uwierzytelnianie trzeba testować w trakcie procesu

Testowanie wyłącznie początkowego przekierowania z chronionej strony nie wystarcza.

Sesja może wygasnąć, gdy użytkownik:

  • edytuje długi formularz,
  • czeka na płatność,
  • wysyła plik,
  • przechodzi przez kilka kroków onboardingu,
  • ma aplikację otwartą w innej karcie.

Realistyczny test powinien obejmować:

Użytkownik otwiera uwierzytelniony checkout
→ wprowadza dane
→ sesja wygasa
→ wysłanie zwraca unauthorized
→ użytkownik zostaje poproszony o uwierzytelnienie
→ aplikacja zachowuje bezpieczny adres powrotu
→ proces może być kontynuowany bez podwójnego wysłania

Nie zapisuj sekretów ani niebezpiecznych treści żądania w adresach URL przekierowań.

Właściwa strategia odzyskiwania zależy od wrażliwości danych.

Testy autoryzacji należą do granicy serwerowej

Ukrycie przycisku nie jest autoryzacją.

Testuj, czy nieuprawnione żądania są odrzucane także wtedy, gdy zostaną przygotowane ręcznie.

Na przykład:

Użytkownik z tenantu A żąda zamówienia z tenantu B
→ serwer odrzuca dostęp
→ odpowiedź nie zawiera chronionych danych
→ zdarzenie jest logowane zgodnie z polityką projektu

Test przeglądarkowy może sprawdzać zachowanie widoczne dla użytkownika.

Test integracyjny niższego poziomu powinien weryfikować faktyczną granicę autoryzacji.

Testuj nawigację i przekierowania

Błędy przekierowań pojawiają się często, gdy:

  • giną prefiksy językowe,
  • znikają parametry query string,
  • callback URL jest błędnie kodowany,
  • przekierowanie uwierzytelniające wpada w pętlę,
  • udana mutacja przekierowuje do nieaktualnych danych,
  • historia przeglądarki zachowuje się nieoczekiwanie.

Dla krytycznych procesów sprawdzaj:

  • docelowy URL,
  • zachowanie języka,
  • zachowanie bezpiecznego adresu powrotu,
  • działanie przycisku Wstecz,
  • bezpośrednie wejście na trasę potwierdzenia,
  • odświeżenie po sukcesie.

Nie testuj każdego linku w przeglądarce.

Skup testy przeglądarkowe na przejściach procesu.

Zachowanie cache wymaga osobnych testów

Cache może sprawić, że poprawna mutacja wygląda jak zepsuta.

Przykład:

Użytkownik aktualizuje adres dostawy
→ backend zapisuje zmianę
→ aplikacja przekierowuje na stronę adresu
→ strona z cache pokazuje poprzedni adres

Test powinien weryfikować kontrakt widoczny z zewnątrz:

Po zaakceptowanej mutacji kolejny istotny odczyt pokazuje nową wartość.

W zależności od architektury projektu może to wymagać:

  • unieważnienia cache,
  • unieważnienia tagów,
  • rewalidacji ścieżki lub trasy,
  • wersjonowania klucza cache,
  • pominięcia cache dla danych użytkownika.

Używaj mechanizmu cache faktycznie skonfigurowanego w projekcie.

Nie testuj szczegółów wewnętrznych frameworka.

Testuj, czy nieaktualne dane nie są pokazywane tam, gdzie wymagana jest świeżość.

Unikaj globalnych założeń dotyczących cache

Te same dane mogą mieć różne wymagania dotyczące świeżości:

Publiczny opis produktu
→ może tolerować krótką nieaktualność

Dostępny stan magazynowy podczas checkoutu
→ wymaga aktualnej walidacji na serwerze

Preferencja profilu użytkownika
→ zwykle powinna natychmiast odzwierciedlać zaakceptowaną zmianę

Końcowy status płatności
→ musi zostać potwierdzony przez autorytatywny backend

Testy powinny odzwierciedlać wymagania biznesowe, a nie uniwersalną zasadę „cache jest dobry” albo „cache jest zły”.

Testuj aktualizacje optymistyczne także przy odrzuceniu

Interfejsy optymistyczne poprawiają odczuwaną szybkość, ale wprowadzają dodatkowe stany.

Przykład:

Użytkownik zmienia ilość produktu z 1 na 3
→ UI natychmiast pokazuje 3
→ serwer informuje, że dostępne są tylko 2 sztuki

Oczekiwane zachowanie musi zostać zdefiniowane:

Przywróć 1
albo
pokaż 2 jako zaakceptowane maksimum
albo
poproś użytkownika o potwierdzenie zmienionego koszyka

Testuj:

  • stan optymistyczny,
  • zaakceptowaną odpowiedź,
  • odrzuconą odpowiedź,
  • rollback,
  • ogłoszenie błędu,
  • interakcję podczas oczekiwania.

Nie pozostawiaj zachowania rollbacku bez jawnej definicji.

Testy przeglądarkowe powinny obejmować procesy, a nie szczegóły implementacji

Test przeglądarkowy powinien opisywać cel użytkownika. Poniższy przykład używa składni Playwright, ale odpowiedzialność testu nie zależy od jednego narzędzia:

test('customer can complete checkout after correcting changed stock', async ({
    page,
}) => {
    await page.goto('/en/products/example-product');

    await page.getByRole('button', {
        name: /add to basket/i,
    }).click();

    await page.getByRole('link', {
        name: /checkout/i,
    }).click();

    await page.getByLabel(/delivery address/i)
        .selectOption('address-1');

    await page.getByRole('button', {
        name: /place order/i,
    }).click();

    await expect(
        page.getByRole('alert'),
    ).toContainText(/quantity has changed/i);

    await page.getByRole('button', {
        name: /accept changes/i,
    }).click();

    await expect(page).toHaveURL(/\/orders\/[^/]+\/confirmation/);

    await expect(
        page.getByRole('heading', {
            name: /order confirmed/i,
        }),
    ).toBeVisible();
});

Dostosuj trasy, selektory i składnię do rzeczywistej aplikacji.

Preferuj selektory dostępności zamiast szczegółów implementacyjnych CSS.

Kontroluj stan backendu w sposób celowy

Testy przeglądarkowe stają się niestabilne, gdy zależą od niekontrolowanych współdzielonych danych.

Używaj przyjętego podejścia do danych testowych:

  • konfiguracji testu przez API,
  • deterministycznych fixture’ów,
  • izolowanej testowej bazy danych,
  • dedykowanych fabryk,
  • zatwierdzonej strategii resetowania bazy.

Test powinien znać:

  • stan magazynowy produktu,
  • uprawnienia użytkownika,
  • stan koszyka,
  • tryb awarii backendu,
  • oczekiwany wynik zamówienia.

Nie polegaj na kontach przypominających produkcyjne, współdzielonych przez kilka testów.

Nie uruchamiaj destrukcyjnego przygotowania danych na rzeczywistym środowisku.

Symuluj awarie na kontrolowanych granicach

Przydatne punkty wstrzykiwania awarii obejmują:

Adapter API
Dostawca uwierzytelniania
Gateway płatności
Data loader
Usługa wysyłania plików
Dostawca feature flag
Endpoint statusu kolejki

Unikaj szerokiego przechwytywania, które przypadkowo mockuje niezwiązane żądania.

Test powinien jasno pokazywać zamierzoną awarię:

await checkoutApi.respondOnce({
    status: 409,
    body: {
        code: 'basket_changed',
    },
});

Następnie sprawdź odpowiadające jej zachowanie naprawcze.

Nie mockuj całej aplikacji

Nadmierne mockowanie tworzy testy, które jedynie potwierdzają, że mocki zgadzają się z implementacją.

Test komponentu może zastąpić jego gateway.

Test integracyjny trasy powinien używać bardziej rzeczywistego routingu i serializacji.

Test procesu w przeglądarce powinien uruchamiać prawdziwy frontend oraz kontrolowane środowisko backendowe.

Wybierz najmniejszą granicę, która nadal dowodzi, że określone ryzyko jest zabezpieczone.

Stosuj testy kontraktowe między Next.js a backendem

Gdy Next.js korzysta z API Symfony, Laravel albo innego backendu, obie strony muszą uzgadniać:

  • status HTTP,
  • uwierzytelnianie,
  • kształt żądania,
  • kształt odpowiedzi,
  • kody błędów,
  • wartości nullable,
  • formaty dat,
  • paginację,
  • idempotencję,
  • wersjonowanie.

Testowanie kontraktów może opierać się na:

  • schemacie API,
  • współdzielonych fixture’ach,
  • zapisanych i zatwierdzonych przykładach,
  • testach dostawcy i konsumenta,
  • bezpośrednich testach integracyjnych.

Użyteczny test sprawdza więcej niż odpowiedź 200:

it('maps a completed checkout response', async () => {
    const fetcher = createFetchStub({
        status: 201,
        json: {
            status: 'completed',
            orderId: 'order-123',
            confirmationNumber: 'ORD-123',
        },
    });

    const gateway = new HttpCheckoutGateway(
        'https://backend.test',
        fetcher,
    );

    await expect(
        gateway.submit({
            basketId: 'basket-1',
            deliveryAddressId: 'address-1',
            idempotencyKey: 'checkout-attempt-1',
        }),
    ).resolves.toEqual({
        status: 'completed',
        orderId: 'order-123',
        confirmationNumber: 'ORD-123',
    });
});

Testuj także rzeczywiste formaty błędów dostawcy.

Podwójne wysłanie wymaga idempotencji

Wyłączenie przycisku ogranicza przypadkowe duplikaty, ale nie zapewnia pełnej ochrony.

Duplikaty mogą nadal powstać przez:

  • ponowienia przeglądarki,
  • proxy sieciowe,
  • odświeżenie strony przez użytkownika,
  • wiele kart,
  • ponowne dostarczenie wiadomości z kolejki,
  • opóźnione odpowiedzi.

Dla mutacji mających konsekwencje backend powinien obsługiwać klucz idempotencji albo równoważne ograniczenie biznesowe.

Test frontendu powinien sprawdzać:

Jedna próba użytkownika
→ jeden stabilny klucz idempotencji
→ ponowienia wykorzystują ten sam klucz
→ nowa, zamierzona próba otrzymuje nowy klucz

Za egzekwowanie idempotencji nadal odpowiada backend.

Celowo testuj warunki wyścigu

Typowy wyścig występuje, gdy użytkownik zmienia wybór, a wcześniejsze żądanie nadal oczekuje.

Przykład:

Użytkownik wyszukuje „symfony”
→ rozpoczyna się żądanie A
→ użytkownik zmienia zapytanie na „nextjs”
→ rozpoczyna się żądanie B
→ żądanie B kończy się
→ żądanie A kończy się później

Strona nie może zastąpić nowszego wyniku nieaktualnymi danymi.

Testuj odpowiedzi kończące się poza kolejnością:

it('does not display an older search response', async () => {
    const first = createDeferred();
    const second = createDeferred();

    const search = vi
        .fn()
        .mockReturnValueOnce(first.promise)
        .mockReturnValueOnce(second.promise);

    render();

    await userEvent.type(
        screen.getByRole('searchbox'),
        'symfony',
    );

    await userEvent.clear(
        screen.getByRole('searchbox'),
    );

    await userEvent.type(
        screen.getByRole('searchbox'),
        'nextjs',
    );

    second.resolve(resultFor('nextjs'));

    expect(
        await screen.findByText('Next.js result'),
    ).toBeVisible();

    first.resolve(resultFor('symfony'));

    expect(
        screen.queryByText('Symfony result'),
    ).not.toBeInTheDocument();
});

Użyj anulowania, identyfikatora żądania albo mechanizmu zarządzania stanem odpowiedniego dla implementacji.

Hydration i różnice środowiskowe wymagają uwagi

Kod może działać podczas nawigacji po stronie klienta, a zawodzić po bezpośrednim otwarciu strony.

Typowe przyczyny obejmują:

  • dostęp do window podczas wykonania na serwerze,
  • inne użycie bieżącego czasu po stronie serwera i klienta,
  • różnice formatowania zależne od locale,
  • losowe wartości podczas renderowania,
  • dostęp do storage przeglądarki przed zamontowaniem komponentu,
  • różne wartości feature flag na serwerze i kliencie.

Chroń ważne strony testami obejmującymi:

Bezpośrednią nawigację
Nawigację po stronie klienta
Odświeżenie
Wstecz i Dalej
Inny język
Stan uwierzytelniony i anonimowy

Nie wyciszaj ostrzeżeń hydration bez znalezienia źródła rozbieżności.

Testuj puste, częściowe i nieoczekiwane dane

Dane produkcyjne rzadko są tak kompletne, jak sugerują fixture’y.

Testuj:

  • puste listy,
  • brakujące opcjonalne obrazy,
  • usunięte rekordy powiązane,
  • nullable pola profilu,
  • nieobsługiwany język,
  • częściowe uprawnienia,
  • nieznane wartości enumów,
  • starsze rekordy backendu,
  • bardzo długi tekst,
  • wartości zerowe i maksymalne.

Strona nie powinna się wywracać dlatego, że brakuje jednej opcjonalnej relacji.

Jednocześnie wymagane dane biznesowe nie powinny po cichu stawać się opcjonalne tylko po to, żeby test przeszedł.

Dostępność należy do testowania procesu

Proces jest niekompletny, jeśli działa wyłącznie za pomocą myszy.

Testy komponentów i przeglądarki powinny weryfikować istotne zachowanie dotyczące:

  • dostępnych nazw,
  • etykiet formularzy,
  • przenoszenia fokusu,
  • aktywacji klawiaturą,
  • komunikatów walidacyjnych,
  • podsumowań błędów,
  • fokusu w modalu,
  • komunikatów o ładowaniu.

Po nieudanym wysłaniu formularza sprawdź, czy użytkownik potrafi odnaleźć błąd i kontynuować.

Nie polegaj wyłącznie na wizualnych screenshotach.

Testy wizualne są użyteczne, ale mają ograniczenia

Testy regresji wizualnej mogą chronić:

  • duże zmiany układu,
  • brakujące sekcje responsywne,
  • uszkodzone szkielety ładowania,
  • overflow,
  • nieprawidłowe motywy.

Nie dowodzą:

  • autoryzacji,
  • poprawności danych API,
  • idempotencji,
  • udanego wysłania,
  • dostępnych nazw,
  • zachowania przy ponowieniu.

Używaj testów wizualnych jako jednej warstwy, a nie zamiennika asercji zachowania.

Testuj responsywne zachowanie procesu

Checkout działający na desktopie może zawieść na urządzeniu mobilnym, ponieważ:

  • przycisk wysyłania jest zasłonięty przez element fixed,
  • klawiatura zakrywa akcje formularza,
  • podsumowanie błędów znajduje się poza viewportem,
  • dialogu nie można przewijać,
  • wybór adresu wymaga hover,
  • nawigacja zamyka się nieoczekiwanie.

Dla krytycznych procesów stosuj niewielką liczbę testów na reprezentatywnych viewportach.

Nie duplikuj każdego testu przeglądarkowego dla wielu wymiarów.

Dobieraj kombinacje według ryzyka.

Utrzymuj testy przeglądarkowe skupione i niezależne

Test przeglądarkowy nie powinien zależeć od danych utworzonych przez poprzedni test przeglądarkowy.

Preferuj:

Przygotuj kontrolowany stan
→ wykonaj jeden proces
→ sprawdź jego wynik
→ posprzątaj przez ustaloną infrastrukturę testową

Unikaj:

Test 1 tworzy konto
→ Test 2 oczekuje tego konta
→ Test 3 modyfikuje to samo konto

Współdzielone sekwencyjne procesy utrudniają diagnozowanie awarii.

Chroń zestaw testów przed fałszywą pewnością

Test może przechodzić z niewłaściwego powodu.

Przykłady:

  • wzorzec przechwytywania żądania nigdy nie został dopasowany,
  • asercja sprawdziła nieaktualny element,
  • test nieoczekiwanie użył uwierzytelnienia z cache,
  • formularz nie został wysłany,
  • test przeglądarkowy został pominięty przez konfigurację środowiska,
  • szeroki handler wyjątków ukrył błąd,
  • test akceptował każdy status mniejszy niż 500.

Stosuj asercje dowodzące, że ważna akcja rzeczywiście się wydarzyła.

Na przykład:

Sprawdź payload żądania
Sprawdź wynikową trasę
Sprawdź zapisany stan backendu
Sprawdź widoczne potwierdzenie

Nie każdy test potrzebuje wszystkich czterech sygnałów, ale procesy krytyczne powinny zawierać więcej niż jeden.

Zachowanie produkcyjne domyka strategię testowania

Testy przed wydaniem nie odtworzą każdej kombinacji:

  • przeglądarki,
  • sieci,
  • wolumenu danych,
  • awarii dostawcy,
  • momentu wdrożenia,
  • stanu cache,
  • długości sesji.

Weryfikacja produkcyjna powinna obejmować:

  • współczynnik błędów tras,
  • nieudane wywołania API,
  • współczynnik ukończenia procesów,
  • miejsca porzucenia checkoutu,
  • pętle przekierowań uwierzytelniających,
  • wyjątki po stronie klienta,
  • naruszenia kontraktu backendu,
  • percentyle opóźnień,
  • skuteczność cache,
  • wykrywanie zduplikowanych akcji.

Użyteczną metryką nie jest wyłącznie:

Checkout API zwróciło 201.

Jest nią:

Użytkownik otworzył checkout
→ wysłał zamówienie
→ dotarł do stanu potwierdzonego zamówienia

Mierz ukończenie procesu biznesowego tam, gdzie pozwala na to polityka prywatności i monitoringu.

Dodawaj identyfikatory korelacji

Proces może przechodzić przez:

Przeglądarkę
→ serwer Next.js
→ API backendu
→ dostawcę płatności
→ kolejkę wiadomości

Bez identyfikatora korelacji jedna awaria wygląda jak kilka niezwiązanych logów.

Używaj przyjętego identyfikatora żądania lub procesu, który można bezpiecznie propagować.

Nie ujawniaj bez potrzeby wrażliwych identyfikatorów wewnętrznych.

Testy mogą sprawdzać, czy identyfikator:

  • jest tworzony albo przyjmowany na właściwej granicy,
  • jest wysyłany do backendu,
  • trafia do bezpiecznych logów,
  • jest zwracany tam, gdzie to właściwe,
  • nie jest nieprzewidywalnie generowany ponownie w ramach jednego procesu.

Stosuj macierz testów opartą na ryzyku

Nie każda strona wymaga takiej samej głębokości.

Klasyfikuj procesy:

Krytyczne
→ płatności, uwierzytelnianie, uprawnienia, nieodwracalne zmiany

Ważne
→ publikowanie treści, ustawienia konta, przesyłanie dokumentów

Wspierające
→ filtrowanie, opcjonalne preferencje, strony informacyjne

Proces krytyczny może wymagać:

  • testów jednostkowych,
  • testów błędów komponentów,
  • testów kontraktowych adaptera,
  • testów autoryzacji,
  • testu udanej ścieżki w przeglądarce,
  • testu odzyskiwania w przeglądarce,
  • monitoringu produkcyjnego.

Funkcja wspierająca może potrzebować jedynie skupionych testów komponentowych i integracyjnych.

Celem nie jest identyczne pokrycie.

Celem jest ochrona proporcjonalna do ryzyka.

Praktyczna kolejność wdrożenia

Faza 1: Zmapuj proces

Udokumentuj:

  • punkt początkowy,
  • cel użytkownika,
  • zależności serwerowe,
  • interakcje po stronie klienta,
  • końcowy autorytatywny stan.

Faza 2: Utwórz macierz awarii

Wypisz:

  • błędy walidacji,
  • błędy autoryzacji,
  • awarie zależności,
  • stany przekroczenia czasu,
  • zduplikowane akcje,
  • nieaktualne dane,
  • częściowy sukces.

Dla każdego przypadku zdefiniuj oczekiwaną możliwość odzyskania działania.

Faza 3: Potwierdź granice architektoniczne

Zidentyfikuj:

  • kontrakty warstwy aplikacyjnej,
  • adaptery infrastruktury,
  • moduły server-only,
  • komponenty klienckie,
  • walidację odpowiedzi w czasie wykonania.

Dodaj egzekwowalne reguły importów tam, gdzie projekt już je obsługuje.

Faza 4: Dodaj testy niższego poziomu

Testuj:

  • reguły domenowe,
  • typy wyników przypadków użycia,
  • mapowanie odpowiedzi,
  • klasyfikację błędów,
  • zachowanie klucza idempotencji.

Faza 5: Dodaj testy zachowania komponentów

Testuj:

  • walidację,
  • stan oczekiwania,
  • zapobieganie duplikatom,
  • błędy możliwe do obsłużenia,
  • zachowanie danych użytkownika,
  • rollback aktualizacji optymistycznej.

Faza 6: Dodaj testy integracyjne

Testuj:

  • uwierzytelnianie,
  • autoryzację,
  • odpowiedzi tras,
  • kontrakty API,
  • unieważnianie cache,
  • przekierowania.

Faza 7: Dodaj krytyczne procesy przeglądarkowe

Uwzględnij:

  • jedną udaną ścieżkę,
  • jedną ważną ścieżkę odzyskiwania,
  • jeden przypadek autoryzacji albo wygaśnięcia sesji, jeśli uzasadnia go ryzyko.

Faza 8: Dodaj sygnały produkcyjne

Mierz:

  • ukończenie procesu,
  • współczynnik awarii technicznych,
  • skuteczność odzyskiwania,
  • opóźnienie,
  • naruszenia kontraktów,
  • zduplikowane akcje.

Na czym powinien skupiać się code review

Podczas przeglądu zmiany w Next.js zapytaj:

  • Czy dane server-only są importowane przez kod kliencki?
  • Czy komponent bezpośrednio wywołuje infrastrukturę?
  • Czy dane API są walidowane w czasie wykonania?
  • Czy kontrole autoryzacji są egzekwowane na serwerze?
  • Co dzieje się po przekroczeniu czasu zależności?
  • Czy użytkownik może bezpiecznie ponowić operację?
  • Czy mutację można wysłać dwukrotnie?
  • Czy klucz idempotencji pozostaje stabilny między ponowieniami?
  • Czy zaakceptowane dane są unieważniane albo odświeżane?
  • Czy nieaktualne żądanie może nadpisać nowszy stan?
  • Czy bezpośrednia nawigacja zachowuje się tak samo jak nawigacja kliencka?
  • Czy stany ładowania, pusty i błędu są dostępne?
  • Czy test chroni zachowanie, czy szczegóły implementacji?
  • Czy pełny ważny proces jest gdzieś zabezpieczony?
  • Czy monitoring produkcyjny ujawni awarię procesu?

Reguły rozwoju wspieranego przez AI

Asystentowi programistycznemu AI nie należy zlecać wyłącznie:

Dodaj testy do tej strony.

Przekaż mu jawne odpowiedzialności:

Sprawdź stronę, kontrakt warstwy aplikacyjnej, adapter API i istniejące testy.

Zidentyfikuj:
- stany sukcesu,
- stany ładowania,
- odrzucenia biznesowe,
- awarie techniczne,
- zmiany uwierzytelnienia,
- ryzyko podwójnego wysłania,
- unieważnianie cache,
- kompletny proces użytkownika.

Użyj istniejącego test runnera i frameworka testów przeglądarkowych.
Nie dodawaj pakietów.
Nie zastępuj interfejsów należących do projektu bezpośrednimi wywołaniami fetch.
Nie sprawdzaj prywatnych szczegółów implementacji.
Dla każdego ryzyka dodaj najmniejszą warstwę testu, która go dowodzi.

Przed zaakceptowaniem testów wygenerowanych przez AI sprawdź, czy mogą zawieść przy zamierzonym błędzie.

Test, który nie może zawieść, gdy zachowanie jest zepsute, jest dokumentacją, a nie ochroną.

Wnioski senior developera

Testowanie aplikacji Next.js nie jest konkursem na największą liczbę testów.

Celem jest stworzenie dowodów, że architektura pozostaje zrozumiała, awarie są kontrolowane, a użytkownicy mogą wykonać ważną pracę.

Dojrzała strategia zwykle chroni:

  • kierunek zależności,
  • odpowiedzialności serwera i klienta,
  • kontrakty API w czasie wykonania,
  • uwierzytelnianie i autoryzację,
  • ładowanie i odzyskiwanie po błędach,
  • idempotentne mutacje,
  • cache i świeżość danych,
  • warunki wyścigu,
  • krytyczne procesy przeglądarkowe,
  • ukończenie procesów na produkcji.

Najsilniejszy zestaw testów nie próbuje wykonywać każdej możliwej kombinacji przez przeglądarkę.

Umieszcza każde ryzyko na najniższej wiarygodnej warstwie i łączy te warstwy niewielką liczbą realistycznych procesów użytkownika.

To właśnie sprawia, że aplikację można bezpiecznie refaktoryzować.

Zwiększa to również bezpieczeństwo rozwoju wspieranego przez AI. Narzędzie AI może szybko generować komponenty, adaptery i testy, ale projekt musi wcześniej definiować granice, oczekiwania wobec awarii oraz procesy, które wygenerowany kod ma zachować.

Udane żądanie nie jest tym samym co udany proces użytkownika.

Wyrenderowana strona nie jest tym samym co chroniona architektura.

Przechodzący zestaw testów ma wartość tylko wtedy, gdy zawodzi przy problemach, przed którymi biznes rzeczywiście musi się chronić.