Kolejny artykuł dotyczący testowania w React będzie skupiał się na testowaniu jednostkowym funkcji. Jest to drugi artykuł dotyczący testowania, w związku z tym informacje przedstawione tutaj, będą łatwe do przyswojenia. Łatwe będzie też wdrożenie tego typu testów do Waszych projektów, do czego oczywiście zachęcam.

Na początku przypomnę jeden artykuł dotyczący pisania czystych funkcji, bo to w jaki sposób funkcja została napisana, będzie rzutować na łatwość pisania testów.
Link do artykułu: Programowanie funkcyjne – czyste funkcje – https://www.magicweb.pl/programowanie/programowanie-funkcyjne-czyste-funkcje/

Dlaczego jest to tak ważne?
Jeżeli chcemy testować funkcje jednostkowo, powinny one działać niezależnie od zewnętrznych źródeł, a dla tych samych parametrów wejściowych powinniśmy otrzymać zawsze ten sam wynik na wyjściu. Takie podejście pozwoli na bardzo szybkie i łatwe znalezienie problemu, kiedy test zwróci błąd.

Przykład testu

Stworzymy bardzo prostą funkcję add(), która dodaje dwie liczby z pewnym warunkiem. Jeżeli wynikiem jest liczba mniejsza od 0, zwracamy 0 zamiast liczby ujemnej.

export const add = (x, y) => {
    const sum = x + y;

    if (sum < 0) {
        return 0;
    }

    return sum;
}

Aby przetestować funkcję, należy sprawdzić nie tylko dodawanie dwóch liczb, dla których wynik będzie dodatni, ale również przypadki, gdy wynik będzie liczbą ujemną bo taka logika występuje w kodzie… Na pewno pomocne będzie też dodanie przypadków granicznych dla warunku. W ten sposób każda linia funkcji będzie pokryta testami.

Testy funkcji mogłyby wyglądać mniej więcej tak:

describe('add() function', () => {
    test('1 + 1 = 2', () => {
        expect(add(1, 1)).toBe(2);
    });

    test('-1 + -1 = 0', () => {
        expect(add(-1, -1)).toBe(0);
    });

    test('0 + 0 = 0', () => {
        expect(add(0, 0)).toBe(0);
    });

    test('0 + 1 = 1', () => {
        expect(add(0, 1)).toBe(1);
    });

    test('0 + -1 = 0', () => {
        expect(add(0, -1)).toBe(0);
    });
});

Dlaczego wspomniałem o przypadkach granicznych? W momencie zmiany warunku w funkcji, testy z warunkami granicznymi mogą być pomocne przy wykryciu błędu. Oczywiście mająć bardziej zaawansowane funkcji, napisanie takich testów, które zabezpieczą wszystkie przypadki będzie trudniejsze, natomiast zapewnienie pokrycia wszystkich linii i każdego warunku zwiększy prawdopodobieństwo uniknięcia błędów.

Zmieńmy teraz warunek w funkcji:

    if (sum < -1) {
        return 0;
    }

W tym momencie uruchomienie testów, zwróci nam błąd:

 FAIL  src/utils.test.js
  ● add() function › 0 + -1 = 0

    expect(received).toBe(expected) // Object.is equality

    Expected: 0
    Received: -1

      19 |
      20 |     test('0 + -1 = 0', () => {
    > 21 |         expect(add(0, -1)).toBe(0);
         |                            ^
      22 |     });
      23 | });

Dzięki temu, że testy zawierały sprawdzenie dla wartości granicznych udało nam się znaleźć błąd. Gdybyśmy mieli napisany tylko jeden test, np. zawierający dodanie dwóch liczb dodatnich nie wykrylibyśmy błędu. Warto zatem czasami dostosować testy do przypadku.

Inne metody służące do testów

Podczas pisania testów, mamy do dyspozycji inne metody, nie tylko metodę toBe(), która służy do porównania wartości. Możemy sprawdzać wartości, wyrażenia regularne, to czy metoda zwróci wyjątek, czy inna funkcja zostanie wywołana itd.
Lista metod znajduje się tutaj: https://jestjs.io/docs/en/expect
Znajdują się tam też przykłady użycia poszczególnych funkcji.

Testowanie prywatnych funkcji

Postaram się przybliżyć jeszcze temat testowania prywatnych funkcji, bo na pewno z takim problemem każdy się spotka. O co chodzi z testowaniem prywatnych funkcji? Wyobraźmy sobie, że posiadamy funkcję, która dokonuje jakiś obliczeń. Nazwijmy ją calculate(). Funkcja ta korzysta z innych dodatkowych funkcji getBonus() oraz getDiscount(). W związku z tym, że w żadnej innej części aplikacji (poza metodą calculate()) nie korzystamy z funkcji getBonus() oraz getDiscount(), nie wystawiamy tych funkcji na zewnątrz – pozostają one prywatne.

Tutaj zaczyna się cała dyskusja. Niektórzy powiedzą, że nie należy testować prywatnych funkcji, a powinny być one testowane tylko poprzez metodę główną. Czyli w tym założeniu powinniśmy napisać testy w taki sposób, aby wywołując metodę calculate() z odpowiednimi parametrami, wywołać funkcję getBonus() oraz getDiscount() z odpowiednimi warunkami.

Z kolei inne podejście mówi, że możemy osobno przetestować funkcje, również te prywatne. Osobiście uważam, że to zależy nie tylko od podejścia, ale od złożoności funkcji i tego co funkcja robi. Spójrzmy na to z innej strony… Celem napisania (w wielkim skrócie) testów jest sprawienie, żeby aplikacja działała dobrze, testy pomagają znaleźć potencjalne bugi itd. Czy zatem przy bardziej złożonej logice testowanie prywatnych funkcji przeszkadza nam w osiągnięciu tego celu, czy raczej pomaga? Odpowiedź w tym przypadku powinna być raczej jasna, chociaż w internecie toczą się tysiące dyskusji na ten temat i wiele osób nie zgadza się z podejściem testowania prywatnych funkcji. Ja osobiście zostanę przy tym, że testowanie prywatnych funkcji pomaga i ułatwia nam życie i warto je testować.

Przyjrzyjmy się przykładowi poniżej:

export const calculate = (amount) => {
    const bonus = getBonus(amount);
    const discount = getDiscount(bonus);

    return {amount, bonus, discount};
};

const getBonus = (amount) => {
    switch (true) {
        case (amount > 200):
            return 30;
        case (amount > 100):
            return 10;
        case (amount > 10):
            return 1;
        default:
            return 0
    }
};

const getDiscount = (bonus) => {
    switch (true) {
        case (bonus > 5):
            return 2;
        case (bonus > 1):
            return 1;
        default:
            return 0;
    }
};

Funkcja calculate() używa dwóch metod, natomiast to, co zwróci funkcja getDiscount() zależy od wartości zwróconej z getBonus(). Żeby przetestować każdy przypadek, musielibyśmy napisać wiele testów. Wydaje mi się, że w takim przypadku rozsądne byłoby napisanie oddzielnych testów dla funkcji getDiscount() i getBonus() oraz oczywiście do publicznej funkcji calculate().

Najprostszym sposobem byłby eksport funkcji, wystawienie jej na zewnątrz i napisanie testów. Tego jednak nie chcemy. Bez eksportu funkcji próba napisania i uruchomienia testu dla metody getBonus() zwróci po prostu błąd.

TypeError: (0 , _provision.getBonus) is not a function

Proponowanym rozwiązaniem jest tutaj np. użycie rewire https://www.wisdomgeek.com/development/web-development/javascript/how-to-unit-test-private-non-exported-function-in-javascript/

lub wystawienie prywatnych funkcji „na zewnątrz” poprzez prosty trick, bez żadnych dodatkowych bibliotek. Funkcje same w sobie pozostaną prywatne, natomiast zostaną „wystawione” poprzez wyeksportowany obiekt.

export const __private__ = {
    getBonus,
    getDiscount
};

Teraz możemy zaimportować __private__ w pliku z testami, a następnie wywołać metody, które chcemy przetestować.

import {__private__} from './provision';

test('getBonus()', () => {
    expect(__private__.getBonus(210)).toBe(30);
})

To by było na tyle, jeżeli chodzi o proste testowanie funkcji. Dzięki!

Źródła:
Obraz: https://unsplash.com/photos/UXNXItayGmo
Lista metod, służąca do walidacji oczekiwanego rezultatu: https://jestjs.io/docs/en/expect