Blog

Jak pisać efektywne testy frontendów?

09.04.2025 | Łukasz Nowak, Senior Frontend Developer

Pisanie testów automatycznych dla aplikacji frontendowych często nastręcza pewnych problemów. Łatwo w nich o niedociągnięcia, mylne założenia, szybkie rozwiązania będące złymi praktykami, a na dokładkę wszystko to jest dość czasochłonne. Jednak praca włożona w szlifowanie umiejętności i używanie właściwych narzędzi pozwoliła stworzyć w HL Tech całkiem niezłą kulturę efektywnego testowania już na wczesnym etapie developmentu.

Po co nam te testy?

Shift-left approach to pojęcie istniejące w świecie testerskim od ponad dwudziestu lat. Zakłada ono weryfikację funkcjonalności już na początkowym etapie developmnetu oprogramowania. W ten sposób można stosunkowo wcześnie wykryć różnorodne nieścisłości. Mam tutaj na myśli błędy popełnione przez developerów, ale także braki w dizajnie czy niepełną analizę pomijającą tzw. scenariusze brzegowe.

Weryfikacja ta polega przede wszystkim na automatyzacji testów które sprawdzają, ,jak działa wdrażane rozwiązanie i czy nie wpłynie ono na uszkodzenie innych komponentów w ekosystemie. Pozwala to na tworzenie w zespołach kultury dobrej jakości i pełności oprogramowania. Developerzy stają się świadomi większej ilości wyzwań związanych z utrzymaniem funkcjonalności w ryzach zaplanowanego scenariusza, co finalizuje się lepszą jakością prac końcowych przekazywanych biznesowi do końcowej akceptacji.

Do właściwego wdrożenia shift-left w codzienny development niezbędna jest dobrze zaprojektowana warstwa dev-ops. Mam tutaj na myśli stworzenie ciągu zadań sprawdzających jakość kodu i oprogramowania, użytych narzędzi oraz wszystkich innych formalności związanych z etapem developmentu. Gdy wszystko to jest spełnione, nowe rozwiązanie może być w sposób automatyczny dostarczone klientom, w zasadzie nawet bez konieczności manualnej weryfikacji po stronie testera.

Zagadnienia te wybiegają jednak daleko poza tematykę tego artykułu. Chciałbym w nim bowiem przybliżyć, w jaki sposób podchodzimy do testowania aplikacji frontendowych w naszym codziennym developmencie.

Weryfikacja jakości kodu

Od kilku lat lubię obserwować, jak młodsi stażem koledzy zaczynają zdawać sobie sprawę, że jakość pisanych przez nich funkcjonalności także jest wynikiem pewnego rodzaju testowania. Mam tutaj na myśli statyczną analizę i automatyczną poprawę kodu źródłowego, realizowaną przez narzędzia pokroju Prettier, Eslint czy Typescript. Są one bowiem nieformalną, najniższą warstwą testów dbających o ogólną czytelność kodu, sprawdzających czy w wywoływanych komendach nie popełniamy literówek i czy nie wybiegamy poza ustalone ramy biznesowe zamknięte w typach danych, na których operujemy. 

Testowanie funkcjonalności na niskim poziomie

Prawdziwe i świadome testowanie zaczyna się jednak na warstwie sprawdzeń jednostkowych i integracyjnych, które tak naprawdę są najważniejsze w naszym codziennym developmencie. Wynika to z prostego faktu, że weryfikują one funkcjonalność w oparciu o surowy kod źródłowy, nie wymagający żadnej kompilacji czy uprzedniej modyfikacji. Wykonujemy je przy użyciu narzędzi Jest lub Vitest, chociaż z powodu większej nowoczesności i ogólnej wydajności, przy nowych projektach zalecamy to drugie.

Warto w tym miejscu zrobić pewną przerwę celem wyjaśnienia, co w HL Tech rozumiemy pod pojęciem testów jednostkowych i integracyjnych. Internetowa wiedza jest bowiem bardzo niejednoznaczna w tym zakresie i powoduje sporo niejasności nawet dla doświadczonych developerów.

Otóż testami jednostkowymi nazywamy sprawdzenie wyników działania pewnej zamkniętej logiki. Chodzi tutaj o proste funkcje, które przy użyciu takich samych argumentów na wejściu zawsze dadzą te same wyniki końcowe. Dobrym przykładem może być mapper, walidator, czy wszelki helper operujący na ograniczonym zakresie wejścia. W takich scenariuszach sprawdzamy możliwie wszystkie kombinacje danych wejściowych, czasami wywołując je manualnie, a czasami próbując wykorzystać pewną reużywalność.

describe('isInRange', () => {
    it.each<{value: number; result: boolean}>([
        {value: -2, result: false},
        {value: -0, result: false},
        {value: 0, result: false},
        {value: 0.1, result: true},
        {value: 5, result: true},
        {value: 9.9, result: true},
        {value: 10, result: false},
        {value: 15, result: false},
    ])('returns $result if value is $value', ({value, result}) => {
        expect(isInRange(value)).toBe(result);
    });
});

Przykład kodu testu jednostkowego.

Testami integracyjnymi w naszym ekosystemie nazywamy natomiast wszystkie testy oparte o renderowanie komponentu w środowisku JSDOM. W tej warstwie krytycznie ważne jest zrozumienie, na czym polega ta tytułowa integracja. W świecie frontendu wciąż obecne jest bowiem błędne przekonanie, że dotyczy ona połączeń pomiędzy częściami kodu – komponentami. Jednak takie podejście powoduje uzależnienie testów integracyjnych od szczegółów implementacyjnych, czyniąc je niemiarodajnymi i ciężkimi do utrzymania w przyszłości. W naszych projektach natomiast uznajemy każdą stronę w aplikacji za samostanowiącą całość, będącą najlepszym kandydatem do testów. W ich trakcie weryfikujemy, jak integrują się one z ich tzw. światem zewnętrznym – użytkownikiem czytającym dane na ekranie oraz korzystającym z aplikacji za pomocą myszki i klawiszy, adresem url w przeglądarce czy api, za pośrednictwem którego widok komunikuje się z backendem.

Testy takie opierają się na dokładnym symulowaniu sposobu, w jaki użytkownik korzysta z naszej aplikacji. Świetnie tutaj sprawdza się biblioteka user-event z funkcjami typu click() czy type() oraz Testing Library z selektorami, wśród których najważniejszym jest getByRole(). Integrację z adresem url realizujemy poprzez włączenie biblioteki obsługującej routing wewnątrz testowanego komponentu. W ten sposób jesteśmy w stanie sprawdzać aktualny adres url 'w przeglądarce’ i potwierdzać wystąpienie oczekiwanych przekierowań. Komunikację z backendem mockujemy natomiast przy użyciu MSW. W tym przypadku jesteśmy bardzo skrupulatni – obsługujemy tylko te endpointy, które powinny być wywołane w trakcie testu i tylko z danymi, które przewidujemy wysłać/odebrać z dokładnością od ostatniego znaku. Nic więcej, nic mniej, ponieważ jakiekolwiek odstępstwo od planowanej integracji z backendem powoduje niewłaściwe procesowanie operacji finansowych. Dla nas może się to szybko przerodzić w znaczące kary nałożone przez rynkowego regulatora.

Testy integracyjne dzielimy na dwa etapy – Happy path i Negative path. Te pierwsze służą potwierdzeniu czy wyświetlane są wszystkie oczekiwane elementy i przejściu przez wszystkie akcje zmierzające do – najogólniej mówiąc – osiągnięcia sukcesu. Dobrym przykładem może być wypełnienie formularza i wysłanie danych zakończone poprawną odpowiedzią z serwera. Drugi etap natomiast humorystycznie nazywamy 'ścieżką zdrowia’, podczas której sprawdzamy zachowanie komponentu przy błędnych odpowiedziach z endpointów, czy niewłaściwych danych wprowadzanych przez użytkowników.

Tutaj muszę się przyznać, że powyższy opis jest tylko zajawką tematu, ponieważ testy integracyjne same w sobie są materiałem na sporej wielkości publikację. Umiejętność pisania miarodajnych, wydajnych i czytelnych scenariuszy jest więc wynikową żmudnego szlifowania warsztatu, poznawania dobrych praktyk i rozumienia całego ciągu zdarzeń wywoływanych każdą akcją na testowanym komponencie. Niestety, naturalna skłonność do korzystania ze skrótów bardzo często kończy się tworzeniem nieefektywnych scenariuszy. Sprawia to kłopoty nawet średnio-zaawansowanym developerom. Moją radą w takiej sytuacji jest cierpliwość i koncentracja na najdokładniejszym symulowaniu sposobu, w jaki użytkownik korzysta z testowanego komponentu. Reszta przyjdzie z czasem.

it('redirects client to /profile after successful sign up', async () => {
    // Given data used during test scenario
    const login = 'test-login';
    const password = 'test-password';
    const csrfToken = 'test-csrf-token';
    // And handler for POST /login endpoint successful call
    // Note: it handles api endpoint requested only with given requestBody definition
    server.use(loginHandler({login, password, csrfToken}, {isAuthenticated: true}));
    // And url to redirect to after successful sign up
    const redirectionUrl = '/profile';
    // When component renders
    // Note: render helper wraps View with open source libraries like React Router or React Query
    // It also starts the page within a /login url context and provides csrfToken to be mocked internally 
    const {history} = renderPageWithinContexts(<LoginView />, {path: '/login', csrfToken});
    // Then main header is displayed
    expect(await screen.findByRole('heading', {name: /log in to see your profile/i}));
    
    // When client fill a sign-up form
    await userEvent.type(screen.getByRole('textbox', {name: /login/i}), login);
    await userEvent.type(screen.getByRole('textbox', {name: /password/i}), password);
    await userEvent.click(screen.getByRole('button', {name: /sign up/i}));
    // Then they are redirected to /profile page
    await waitFor(() => expect(history.location.pathname).toEqual(redirectionUrl));
});

Przykład kodu testu integracyjnego hipotetycznej strony do logowania.

Testowanie działania aplikacji

O ile poprzednia warstwa testów opiera się na sprawdzaniu wycinków aplikacji – jednostek logicznych, widoków czy stron, tak teraz skupiamy się na weryfikowaniu tzw. user journeys – realnych scenariuszy, koncentrujących się na osiągnięciu przez klienta oczekiwanego rezultatu. Do takiej weryfikacji najczęściej przydaje się nam Playwright. Testuje on produkcyjny bundle aplikacji wygenerowany lokalnie (testy funkcjonalne), lub zdeployowany na środowiska testowe (testy end-to-end / e2e).

Testy funkcjonalne najlepiej sprawdzają się w tzw. aplikacjach krokowych, gdzie klienci osiągają swój cel przez przejście kilku widoków tworzących razem ich tzw. journey. Za przykład może tutaj posłużyć tworzenie przelewu finansowego, który w większości systemów polega na wprowadzeniu danych, potwierdzeniu ich na podsumowaniu, wpisaniu kodu bezpieczeństwa i końcowym wysłaniu zgłoszenia do instytucji finansowej. Natomiast w przypadku aplikacji jednokrokowych, np. paneli przeznaczonych dla pracowników operacyjnych, które przypominają warstwę widoku dla operacji CRUD (create, read, update, delete), testy takie są niemal jednolite z naszymi testami integracyjnymi. Na tym etapie komunikacja z endpointami także jest zamockowana, a sama aplikacja testowana jest z użyciem prawdziwej przeglądarki, czego implementacją zajmuje się najczęściej Playwright.

Testy e2e skupiają się z kolei na zweryfikowaniu regresji krytycznych ścieżek w aplikacji. Za ich tworzenie odpowiedzialni są inżynierowie QA czy też tzw. testerzy. Jednak dzięki użyciu wspólnego języka Typescript i narzędzia Playwright, ich praca może być wykonywana lub weryfikowana także przez developerów frontendowych. Warstwa ta komunikuje się z prawdziwymi serwisami backendowymi, dzięki czemu dokonujemy regresji całości rozwiązania. Testy takie wykonują się każdorazowo po wdrożeniu zmian do głównej gałęzi jakiegokolwiek z komponentów ekosystemu (backend, frontend itp.).

Coś więcej niż testowanie kodu

Jeśli miałbym w jednym zdaniu opisać świat frontendu na przestrzeni kilku ostatnich lat, to nie zawahałbym się użyć słów – bardzo dynamiczny rozwój. Tworzenie tzw. 'ładnych widoczków’ już dawno odeszło do lamusa. W dzisiejszych czasach na froncie skupiamy się bowiem także na semantyce kodu(aby być zgodnym ze standardami SEO), dbamy o wydajność aplikacji, które powinny jak najszybciej wyświetlać klientom oczekiwane dane, czy z uwagą obserwujemy metryki świadczące o – najogólniej mówiąc – komforcie i bezpieczeństwie związanym z korzystaniem z aplikacji. A to tylko drobny wycinek naszych codziennych odpowiedzialności.

Sprawdzenie tego wszystkiego także można zautomatyzować. 'Ładne widoczki’ potwierdzamy poprzez testy wizualne z użyciem Playwright. Polegają one na tworzeniu tzw. snapshotów stron, które następnie zapisywane są do formatu graficznego. W ten sposób możemy potwierdzić, jak strona będzie wyglądać w przeglądarce i czy nie będziemy przypadkowo wprowadzać nieoczekiwanych zmian wizualnych. Metryki związane z wydajnością aplikacji, dobrymi praktykami czy zgodnością z SEO weryfikujemy natomiast poprzez automatyczne audyty snapshotów wykonane w Lighthouse.

Testowanie accessibility

Accessibility to temat niezwykle dla nas ważny. Oprócz wchodzącej w połowie 2025 roku w życie dyrektywy EAA, nakładającej obowiązek spełnienia standardów dostępności usług cyfrowych dla osób z niepełnosprawnościami, po prostu wymaga tego od nas ogromna liczba naszych najcenniejszych klientów.

Oczywiście najbardziej miarodajne testy accessibility są wykonywane przez prawdziwych użytkowników, podobnie jak testy ogólnego User Experience. Jednak część sprawdzeń jesteśmy w stanie zautomatyzować już na wczesnym etapie developmentu. Zwłaszcza jeśli mowa tutaj o standardach dotyczących generowanego kodu HTML – jego semantyki pozwalającej na lepszą integrację z assistive technologies, czy odpowiednio ułożonego wyglądu w zakresie użytych kontrastów, wielkości elementów itp.

Analizę statycznego kodu i wyglądu włączyliśmy do grupy tzw. testów snapshotów. Jesteśmy tutaj bardzo restrykcyjni i w nowych projektach nie pozwalamy na wprowadzenie żadnej zmiany, jeśli wykryty zostanie jakikolwiek błąd niezgodny z najpełniejszymi wytycznymi WCAG. Testowanymi aplikacjami sterujemy przy użyciu Playwright, po czym ich efekt analizujemy w Lighthouse i skanerami Axe.

public async assertSnapshots(snapshotTitle: string) {
    await Promise.all([
        this.assertAccessibilityChecks(snapshotTitle),
        this.performVisualSnapshotComparison(snapshotTitle),
        this.performAriaSnapshotComparison(snapshotTitle),
        this.performLighthouseFlowSnapshot(snapshotTitle),
    ]);
}
 
private async assertAccessibilityChecks(snapshotTitle: string) {
    // Perform static a11y analysis of given page (snapshot)
    const accessibilityChecks = await new AxeBuilder({page: this.page})
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
        .analyze();
    // Attach checks into test report
    await this.testInfo.attach(this.composeA11yReportName(snapshotTitle), {
        body: JSON.stringify(accessibilityChecks, null, 2),
        contentType: 'application/json',
    });
    // Ensure no a11y errors
    expect(accessibilityChecks.violations).toEqual([]);
}

Typy testów snapshotów oraz przykład wywołania statycznej analizy Axe.

Oprócz tego dublujemy scenariusze testujące krytyczne ścieżki client journeys, nawigując wyłącznie przy użyciu klawiatury. W tym celu także posługujemy się Playwright, natomiast przypadki testowe skupiają się bardziej na weryfikowaniu aktualnie zaznaczonych elementów oraz wywoływaniu na nich akcji przy użyciu przycisków na klawiaturze. W ten sposób potwierdzamy, że nasze usługi dostępne są także dla użytkowników korzystających z tzw. assistive technologies.

Wszystko powyżej to tylko zajawka

Bez dwóch zdań, testowanie frontendów to ciężki kawałek developmentu. Niejednokrotnie najprostsze rozwiązania okazują się złym patternem, jak np. identyfikowanie elementów po strukturze HTML, czy poprzez specjalny atrybut data-test-id. Z drugiej strony funkcja getByRole() z Testing Library z racjonalnych powodów technologicznych nie grzeszy wydajnością. W ten sposób znajomość wszelkich tips&tricks zmierzających do jej zwiększenia staje się swoistym weryfikatorem dojrzałości w zakresie pisania testów. A z każdym dniem okazuje się, że można poprawić coś jeszcze – aby scenariusze nie tylko były odporne na szczegóły implementacyjne aplikacji, ale też na elementy losowości danych, kolejności wystąpienia akcji czy konfiguracji środowisk na których są wykonywane.

Tematy te od lat omawiamy na naszych wewnętrznych meetupach, dzięki czemu z roku na rok potrafimy tworzyć testy coraz lepsze jakościowo. Zdajemy sobie sprawę, że wydłuża to czas developmentu funkcjonalności. Nie przesadzam stwierdzając, że napisanie wszystkich testów do nowej funkcjonalności zajmuje nieco więcej czasu niż napisanie jej samej. Co jednak osobiście doceniam w codziennej pracy w HL Tech, to to, że biznes jest świadomy wartości testów automatycznych i nigdy nie zadaje pytań o możliwość zastosowania jakiś półśrodków. Na rynku finansowym weryfikowalność jest na szczęście jedną z najważniejszych odgórnych regulacji.

poprzedni
Z pasji do dźwięków. Poznajcie historię Łukasza Nowaka