Home

Dlaczego warto pisać snapshot testy?

Snapshot testy nie są zbyt popularne – albo nie są dość dobrze znane, albo nie są traktowane poważnie. Czy słusznie?

Czym są snapshot testy?

Snapshot testy, testują z definicji określony „snapshot” czyli klatkę danych/stanu/komponentu – zależnie od tego co testujemy. Nie będziemy więc testować dynamicznych, stanowych komponentów ani zachowań/interakcji, za to w pełni nadadzą się testowania czystych funkcji – ich testowalność to ogromna zaleta funkcyjnego programowania.

Testy te polegają na zapisaniu (w pliku i repozytorium) wyniku takiego testu, aby potem porównywać je przy kolejnych testach. Proces ten wygląda mniej więcej tak:

  1. Uruchom test po raz pierwszy
  2. Test się udaje -> zapisz wynik w pliku
  3. Uruchom test po raz drugi
  4. Sprawdź nowy wynik z zapisanym wynikiem
  5. Jeśli wynik ten sam – test się udał, jest zielono
  6. Jeśli wynik się zmienił – pokaż diff programiście i uwal test
  7. Programista poprawia kod lub ocenia że zmiany są zamierzone
  8. Programista aktualizuje snapshot z nowym stanem
  9. Zapisz nowy stan na dysku
  10. Zacommituj snapshoty w repozytorium

A więc jak widać, polegamy tutaj na zachowaniu wyniku testu, zamiast pisania każdej asercji – jedyny expect w naszym kodzie to porównanie tych wyników.

Pliki snapshotów domyślnie zapisywane są (przez Jesta) w folderze naszego komponentu w podfolderze __snapshots__, który commitujemy! Aczkolwiek, polecam dodać go do ignorowanych katalogów przez nasze IDE. Będzie on duplikować chociażby klasy CSS, więc może przeszkadzać przy refactorze i wyszukiwaniu.

Pliki te są zapisane w formacie zrozumiałym dla człowieka – template stringu z opisem HTML czy pseudo-JSON (zależy co testujemy) i pozwala nam elegancko czytać wynikowy kod – a także porównywać ze sobą wersje i sprawdzać je w code review.

Przykładowe testy

Spójrzmy na kilka przykładów – ja testuję snapshotami de facto wszystko, jednak najważniejsze są dla mnie przy testowaniu czystych funkcji mapujących oraz prostych/dumb/prezentacyjnych komponentów w Reakcie.

Testowanie funkcji mapujących

Funkcje mapujące to dla mnie spory kawałek architektury aplikacji. Używam ich obowiązkowo pomiędzy warstwą API a stanem aplikacji (czyli przeważnie pomiędzy fetch(), a Reduxem/MobXem), co daje mi bezpieczeństwo przed zmianami struktur danych w responsach, a także w selectorach, czyli pomiędzy stanem (Reduxem), a widokiem (komponenty-kontenery).

Przykład – mapujemy profil użytkownika

W naszym stanie posiadamy coś na kształt dwóch takich modeli (będziemy mapować jeden na drugi):

interface IUSer {
    firstName: string;
    lastName: string;
    email: string;
    anonymous: boolean;
    nickname: string;
    avatar?: string;
    // ...
    // I cała masa innych pól
}

interface IUserBadgeData {
    displayName: string;
    avatarSrc: string;
}

Nasz IUser może stanowić dane użytkownika zapisane w Store aplikacji, a IUserBadgeData to na przykład dane (propsy) dla komponentu UI, który wyświetla zdjęcie użytkownika z jego nazwą (np w forum).

Wymagania są takie – Domyślnie komponent powinien pokazać imię i nazwisko oraz avatar (jeśli avatar istnieje). Jeśli avatar nie istnieje – pokazać placeholder obrazka. Jeśli jednak użytkownik chce być anonimowy – także pokazujemy placeholder, a nazwę zamieniamy na anonimową.

Oczywiście jest to idealny scenariusz do podejścia TDD i napisania unit testów do zdefiniowania tego scenariusza… I świetnie, jednak nie tym razem (ale oczywiście nic nie stoi na przeszkodzie by testować jedną funkcję na wiele sposobów).

Napiszmy więc implementacje takiej funkcji

const mapUserToBadgeData = (userModel: IUser): IUserBadgeData
    => ({
        displayName: userModel.anonymous ? 'anonymous' : `${userModel.firstName} ${userModel.lastName}`,
        avatarSrc: (!userModel.avatar || userModel.anonymous) ?
            'path/to/placeholder' : userModel.avatar
    });

Teraz napiszmy snapshot testy – dla użytkownika normalnego i anonimowego:

it('Matches snapshot - normal user', () => {
    const userModel: IUser = {
        firstName: "Łukasz",
        lastName: "Ostrowski",
        email: "lukasz@ostrowski.ninja",
        anonymous: false;
        nickname: "lkostrowski";
        avatar?: "gravatar/...";
        // ...
    };

    expect(mapUserToBadgeData(userModel)).toMatchSnapshot();
});

it('Matches snapshot - anonymous user', () => {
    const userModel: IUser = {
        firstName: "Łukasz",
        lastName: "Ostrowski",
        email: "lukasz@ostrowski.ninja",
        anonymous: true;
        nickname: "lkostrowski";
        avatar?: "gravatar/...";
        // ...
    };

    expect(mapUserToBadgeData(userModel)).toMatchSnapshot();
});

Oczywiście możemy uwzględnić więcej scenariuszy – najlepiej takie, które faktycznie przychodzą z API. Tutaj, różnią się tylko flagą anonymous, jednak możemy uwzględnić sytuacje gdy brakuje imienia czy nazwiska, avatara itd.

Gdy nasz test zostanie uruchomiony, zapisany zostanie plik wyglądający mniej więcej tak:

exports[`Matches snapshot - normal user`] = `
  Object {
    displayName: "Łukasz Ostrowski";
    avatarSrc: "gravatar/...";
  },
`;

exports[`Matches snapshot - anonymous user`] = `
  Object {
    displayName: "anonymous";
    avatarSrc: "path/to/placeholder";
  },
`;

Szybki rzut okiem i jesteśmy w stanie stwierdzić, że dane są ok! Teraz możemy spróbować zepsuć naszą funkcję, odpalić test ponownie i zobaczyć czy wyniki będą się zgadzać.

I jasne, gdy mapujemy obiekt który ma mieć dwa pola, możemy napisać unit test, który bezwzględnie (bez późniejszego udziału programisty) sprawdzi nasze warunki biznesowe – oraz czy pola są prawidłowe.

Jednak wyobraź sobie funkcję, która mapuje kilkadziesiąt pól, na kilkanaście innych. Musiałbyś napisać co najmniej kilkanaście asercji (lub głębokie porównywanie), a zmiany struktur wymagałyby czasochłonnych aktualizacji testów. Co więcej, jeśli zapomnimy napisać asercji dla któregoś pola, możemy łatwo przegapić błąd. Przykładowo – nasz unit test może wykazać prawidłowe mapowanie imienia i avatara, ale nie pokaże nam że funkcja zwróciła dodatkowe pole w obiekcie (a nie powinna), albo że jakieś nieotestowane pole stało się nullem.

Snapshot pozwala nam napisać tylko jedną asercję – która może nie daje super pewności, ale wbrew pozorom testuje dużo – cały kruczek polega na tym, że wymaga rzucenia okiem przez developera – a ten może popełnić błąd i uznać za prawidłowe zachowanie, które wcale takie nie jest.

Polecam w tym przypadku pisać unit testy, a snapshoty mieć „obok” jako dodatkowe zapewnienie, że funkcja działa prawidłowo.

Testowanie komponentów Reactowych

Prawdopodobnie częściej niż funkcje mapujące, piszesz komponenty prezentacyjne w Reakcie – dobrą praktyką jest pisanie ich jak najwięcej, gdyż są łatwo testowalne i łatwe do zrozumienia. Kolejną zaletą tych dumb componentów, jest właśnie podatność na pisanie snapshotów – bo te testują pojedynczy stan – nie poradzą sobie z side effectami.

By ułatwić pisanie testów (wszystkich, nie tylko snapshotów), jest oddzielenie abstrakcji stanu od prezentacji. Dlatego właśnie często piszemy controlled elements – czyli kontrolki, które nie są stanowe, jedynie wyświetlają dane z propsów i emituję event o zmianie. Stan (wartość) takiego pola, trzyma przeważnie rodzic (czy np Redux), dzięki czemu łatwo nam przetestować różne wariacje, po prostu wstrzykując różne wartoście przez propsy – i to jest świetne! Dlatego jeśli posiadasz komponenty ze stanem, typu: collapse, carousel, burger menu – napisz osobno jego widok przyjmujący propsy typu open: boolean, onOpen: () => any czy collapsed: boolean, onToggle = () => any, a następnie stwórz np. HOC, który będzie posiadać jedynie stan (np poprzez hook useState), który przekaże do owrapowanego dumb komponentu. Zalety? Możemy przetestować obie warstwy osobno, obie warstwy razem, a także tworzyć kompozycje na różne sposoby (np wymienić warstwę lokalnego stanu, na warstwę stanu Reduxowego czy Local Storage – zależnie od potrzeb).

Gdy już nauczymy się pisać testowalne komponenty ( ͡° ͜ʖ ͡°), to sprawdźmy jak pomogą nam snapshoty. Najpierw jednak przeróbmy typowe scenariusze unit testów, które z definicji – testują nam pojedynczy, wyizolowany komponent (innym razem napiszę czemu to nie jest za dobry pomysł).

Typowy unit test dla dumb componentu, to:

  1. Sprawdzanie, czy poszczególne elementy renderują się przy odpowiednich propsach (np. jeśli przekażemy prop error={true}do inputa, sprawdzamy czy input.field istnieje).
  2. Sprawdzamy czy interakcje z elementem wywołują callbacki (np. kliknięcie w jakiś element wywołuje callback – odpowiednik Angularowego eventu).

Oczywiście może być to pewne uproszczenie, ale myślę że wiele unitów właśnie tak wygląda. Trochę powtarzalna praca, a na dodatek niekoniecznie słuszna – testujemy w końcu internale komponentu (użytkownika nie do końca obchodzi jaką klasę ma input, raczej jak wygląda). Testując obecność klas i atrybutów, musimy liczyć się z koniecznością utrzymywania testów przy refactorach, a także musimy dodawać nowe asercje za każdym razem gdy pojawi się nowa wariacja wejścia (propsów).

Alternatywą dla punktu 1, będzie właśnie użycie snapshotu. Daruję sobie pisanie implementacji komponentu – każdy to potrafi.

Przykładowe testy customowego komponentu Button – zarówno snapshoty, jak i dodatkowe unity, do sprawdzenia interakcji:

describe('Button component', () => {
    describe('Matches snapshots', () => {
        test('Primary big', () => {
            const { container } = render(
                <Button version="primary" size="big">
                    Big, primary button
                </Button>,
            );

            expect(container).toMatchSnapshot();
        });
        test('Secondary big', () => {
            const { container } = render(
                <Button version="secondary" size="big">
                    Big, secondary button
                </Button>,
            );

            expect(container).toMatchSnapshot();
        });
        test('Primary small', () => {
            const { container } = render(
                <Button version="primary" size="small">
                    Small, primary button
                </Button>,
            );

            expect(container).toMatchSnapshot();
        });
        test('Secondary small', () => {
            const { container } = render(
                <Button version="secondary" size="small">
                    Small, secondary button
                </Button>,
            );

            expect(container).toMatchSnapshot();
        });
    });

    describe('Reacts on events', () => {
        const mockClick = jest.fn();

        const { container } = render(<Button onClick={mockClick}>Button</Button>);

        fireEvent.click(container);

        expect(container).toBeCalled();
    });
});

Jak widać, można testować różne wariacje – niekoniecznie każda ma sens, ale teoretycznie im więcej kombinacji, tym większe prawdopodobieństwo że coś się wysypie.

Snapshoty potrafią zapisać tylko wygenerowany markup – więc w tym przypadku będzie to wynikowy HTML. Dlatego jeśli chcemy sprawdzić np. event onClick – musimy napisać do tego „klasyczny” unit test.

Warto wspomnieć, że polecenia expect() można wywoływać w iteracjach, więc jeśli mamy dużo wersji, możemy przygotować tablicę wariacji propsów i sprawdzić ją bez dodatkowego boilerplate’u.

Wynikowe snapshoty wygenerują nam markup – wystarczy rzucić okiem czy klasy CSS i inne atrybuty się zgadzają. Gdy będziemy refactorować implementację, łatwo zauważymy gdy diff się zmieni – i w którym przypadku.

Kiedy i dlaczego warto pisać snapshot testy?

Snapshoty są jedną z koncepcji zrobienia testów „painless” przez Jesta. Nie zastępują one innych testów, jednak użycie ich zamiast unitów może się sprawdzać, szczególnie przy czasochłonnym testowaniu komponentów UI.

Niewątpliwą zaletą snapshotów jest dużo większa szybkość w pisaniu, a nowe funkcjonalności przeważnie zostają odwzorowane w wynikach.

Snapshoty można wygenerować „od szablonu” razem z podstawowymi plikami komponentów.

Snapshoty pozwalają też na testy regresji – np poprzez testowanie całej aplikacji czy podstron i obserwowaniu kiedy następują niepożądane zmiany kodu.

Warto połączyć snapshoty wraz z testami do visual regression – np screenshotami czy Storybookiem. Tak naprawdę to snapshoty i Storybook mają ze sobą wiele wspólnego w swoich założeniach.

Jakie są wady snapshot testów?

Można zauważyć jeden problem – programista musi potwierdzić, że zmiany zostały zamierzone – może się pomylić, może też stwierdzić że już po 17 i „zatwierdzi” nowe snapshoty, mimo że nie powinien… Ale spokojnie – pamiętajmy że wyniki są w czytelnym formacie dostępne do code review, więc jeśli wraz ze zmianą komponentu Header commitujemy zaktualizowany snapshot komponentu Footer – coś jest nie tak i powinno zostać to wyłapane.

Kolejną wadą będzie testowanie dużych komponentów – bardzo duże snapshoty mogą przestać być czytelne, a zmiana markupu takiego „Buttona” spowoduje tak duży diff, że nikt go i tak nie przeczyta.

Podsumowanie

Jako fan pisania bardzo prostych komponentów z podziałem odpowiedzialności, często używam snapshotów zamiast unit testów – póki co jestem bardzo zadowolony. Piszę je bardzo szybko i nie będę ukrywać – na pewno nie chciałoby mi się tak dokładnie pisać unity. Szybkie pisanie to nie tylko Developer Expierience, ale też większa skłonność biznesu do przeznaczania czasu na quality.

W tym tekście omawiałem przykłady czystych funkcji oraz komponentów Reacta, jednak jeśli przebrniecie przez konfigurację Jesta w takim Angularze – nic nie stoi na przeszkodzie by używać snapshotów z innymi frameworkami (i bez nich).

Mam nadzieję że się podobało – jeśli chcesz się czymś podzielić, możesz napisać do mnie na lukasz@ostrowski.ninja