Testy jednostkowe #2 – atrapy, konwencje tworzenia testów

bug finding
W drugim artykule o testach jednostkowych przedstawię szczególne obiekty wykorzystywane w testach, zwane atrapami. Opiszę czym są i do czego służą Stub, Mock i Spy. Przedstawię także dobre praktyki związane z pisaniem testów jednostkowych, a także pokrótce napiszę czego się wystrzegać przy pisaniu testów.

Atrapy

Jak już wspomniałem w poprzednim artykule, testy jednostkowe powinny testować mały fragment kodu w odizolowaniu od reszty oprogramowania. Testy powinny zawsze dla jednego zestawu danych dawać takie same wyniki. A to znaczy, iż testy nie powinny łączyć się z zewnętrznym API czy też z bazą danych, gdyż w przypadku braku połączenia, testy dadzą niepoprawny wynik.
Testy powinny być również uruchamiane szybko. Jednak czasem musimy skorzystać z „ciężkiego” obiektu, który znacznie wydłuża czas działania testu. Dlatego czasem przydałby się nam jakiś obiekt testowy, który zawierałby jedynie potrzebny nam wycinek funkcjonalności obiektu rzeczywistego.
W takim przypadku należy zasymulować połączenie z bazą danych, czy też zasymulować działanie danego obiektu. Do tego służą nam atrapy.

Stub

Stub to obiekt będący imitacją właściwej implementacji. Jego zadaniem jest wyłącznie zwrócenie wartości.
Stub może być substytutem połączenia z zewnętrzną bazą danych. Spójrzmy na przykład poniżej.
public interface AddressBookRepository {

    List<Recipient> loadRecipients();
}


public class AddressBookService {

    private AddressBookRepository addressBookRepository;
    private List<Recipient> recipientList = new ArrayList<>();

    public AddressBookService(AddressBookRepository addressBookRepository) {
        this.addressBookRepository = addressBookRepository;
    }

    public List<Recipient> getRecipientsSortedBySurname() {
        return addressBookRepository.loadRecipients().stream()
                .sorted(Comparator.comparing(Recipient::getSurname))
                .collect(Collectors.toList());
    }

    public List<Recipient> getAdultRecipients() {
        return addressBookRepository.loadRecipients().stream()
                .filter(recipient -> recipient.getAge() >= 18)
                .collect(Collectors.toList());
    }
} 
Chcemy przetestować klasę AddressBookService. Mamy tutaj metody zwracające posortowanych adresatów oraz dorosłych adresatów. Jednak obydwie metody wykorzystują instancję interfejsu AddressBookRepository, która łączy się z bazą danych i pobiera wszystkich adresatów. Nie chcemy w testach korzystać z połączenia z bazą danych, więc musimy zasymulować to połączenie.
public class AddressBookRepositoryStub implements AddressBookRepository {

    @Override
    public List<Recipient> loadRecipients() {

        Recipient recipient1 = new Recipient("Jan", "Kowalski", 22, "Kraków", "332-774-646");
        Recipient recipient2 = new Recipient("Marek", "Zaremba", 40, "Zamość", "334-007-443");
        Recipient recipient3 = new Recipient("Anna", "Bednarek", 29, "Gdańsk", "649-162-545");

        return Arrays.asList(recipient1, recipient2, recipient3);
    }
} 
Jak widać, stworzyliśmy własną implementację interfejsu AddressBookRepository, której jedynym zadaniem jest zwrócenie przykładowej listy adresatów. Teraz możemy dokonać właściwych testów. Stuba wykorzystamy do przetestowania metody zwracającej posortowanych adrestaów.
class AddressBookServiceTest {

    @Test
    void shouldReturnRecipientsSorted() {

        //given
        AddressBookRepository addressBookRepository = new AddressBookRepositoryStub();
        AddressBookService addressBookService = new AddressBookService(addressBookRepository);

        //when
        List<Recipient> recipients = addressBookService.getRecipientsSortedBySurname();

        //then
        assertThat(recipients.get(0).getSurname(), equalTo("Bednarek"));
        assertThat(recipients.get(1).getSurname(), equalTo("Kowalski"));
        assertThat(recipients.get(2).getSurname(), equalTo("Zaremba"));
    }
} 
Jak widać, nasz Stub zwrócił wartość i dzięki niej mogliśmy przeprowadzić test, a jego wynik nie zależy od żadnych zewnętrznych połączeń.

Mock

Mock również jest imitacją właściwej implementacji. Jednak, w odróżnieniu od Stuba, Mock pozwala na weryfikację zachowania. Spójrzmy na przykład poniżej, w którym przetestujemy drugą z metod klasy AddressBokRepository, która zwraca dorosłych adresatów:
@Test
void shouldReturnAdultRecipients() {

    //given
    AddressBookRepository addressBookRepositoryMock = mock(AddressBookRepository.class);
    AddressBookService addressBookService = new AddressBookService(addressBookRepositoryMock);

    List<Recipient> recipients = Arrays.asList(
            new Recipient("Jan", "Kowalski", 18, "Kraków", "332-774-646"),
            new Recipient("Marek", "Zaremba", 17, "Zamość", "334-007-443"),
            new Recipient("Anna", "Bednarek", 45, "Gdańsk", "649-162-545")
    );
    
    given(addressBookRepositoryMock.loadRecipients()).willReturn(recipients);

    //when
    List<Recipient> result = addressBookService.getAdultRecipients();

    //then
    assertThat(result.size(), equalTo(2));
    assertThat(result.get(0).getAge(), not(lessThan(18)));
    assertThat(result.get(1).getAge(), not(lessThan(18)));

    verify(addressBookRepositoryMock).loadRecipients();
    verify(addressBookRepositoryMock, times(1)).loadRecipients();
    verify(addressBookRepositoryMock, atLeastOnce()).loadRecipients();
    verify(addressBookRepositoryMock, atLeast(1)).loadRecipients();
} 
Mocka tworzymy z wykorzystaniem statycznej metody mock() i jako argument podajemy nazwę klasy do „zamockowania”.
Na Mocku nie ma możliwości wywołania metod rzeczywistego obiektu, dlatego należy działanie tej metody zasymulować (w tym przypadku symulujemy metodę loadRecipients() z klasy AddressBookRepository, tą samą, którą symulowaliśmy w przykładzie ze Stubem). Wykonujemy to z wykorzystaniem metod given().willReturn(). Metoda given() w argumencie przyjmuje wywołanie metody do zasymulowania (wywołanie metody na Mocku), a metoda willReturn() w argumencie przyjmuje wartość, jaką ta metoda powinna zwrócić.
Do tego momentu działanie Mocka jest podobne do działania Stuba. Jednak Mock ma możliwość weryfikacji wywołań metod. Służy do tego metoda verify(). Dzięki niej można zweryfikować czy dana metoda faktycznie została na Mocku wywołana. Przy wykorzystaniu dodatkowych metod takich jak times(), atLeastOnce() i innych, możemy zweryfikować czy i ile razy dana metoda została wywołana.
Mocki dają nam wiele innych możliwości niewymienionych w tym artykule. Możemy m.in. weryfikować jakiego rodzaju argumenty są wysyłane do metod, przechwytywać argumenty i wiele innych. Mocki dają także możliwość wywoływania na nich metod obiektów rzeczywistych, jednak do tego celu zalecane jest wykorzystanie atrap typu Spy.

Spy

Spy jest obiektem o działaniu podobnym do Mocka. Jest także imitacją właściwej implementacji, jednak na Spy możliwe jest wywoływanie zarówno metod rzeczywistych, jak i „zamockowanych”. Można powiedzieć, że Spy jest częściowo Mockiem, a częściowo obiektem rzeczywistym. Z tego też względu jest czasem nazywany częściowym albo połowicznym Mockiem (ang. partial Mock).
Zobaczmy działanie Spy na przykładzie. Dodamy do naszego przykładu 2 dodatkowe klasy: Call i CallService.
public class Call {

    private Recipient recipient;
    private int callTime;

    public Call(Recipient recipient, int callTime) {
        this.recipient = recipient;
        this.callTime = callTime;
    }

    //getters & setters
}


public class CallService {

    public List<Call> getAllCalls() {

        List<Call> calls = new ArrayList<>();
        //code to get all calls from database
        return calls;
    }

    public List<Call> getCallsByRecipient(Recipient recipient) {

        return getAllCalls().stream()
                .filter(call -> call.getRecipient().equals(recipient))
                .collect(Collectors.toList());
    }
} 
Klasa Call przechowuje dane adresata oraz czas połączenia telefonicznego. Klasa CallService posiada dwie metody: getAllCalls(), która łączy się z zewnętrzną bazą danych w celu pobrania danych oraz getCallsByRecipient(), która filtruje wyniki poprzedniej metody i zwraca jedynie obiekty klasy Call zawierające konkretnego adresata.
Jak nietrudno się domyślić, pierwsza metoda wymaga zewnętrznego połączenia, dlatego tą metodę chcielibyśmy „zamockować”. Natomiast nic nie stoi na przeszkodzie, aby drugą z metod wywołać w jej rzeczywistej postaci. Poniżej kod klasy testowej.
class CallServiceTest {

    @Test
    void shouldGetCallsByRecipient() {

        //given
        Recipient recipient1 = new Recipient("Jan", "Nowak", 22, "Wrocław", "544-661-112");
        Recipient recipient2 = new Recipient("Adam", "Kowalski", 35, "Chorzów", "223-717-009");
        CallService callServiceSpy = Mockito.spy(CallService.class);

        given(callServiceSpy.getAllCalls()).willReturn(Arrays.asList(
                new Call(recipient1, 97),
                new Call(recipient2, 121),
                new Call(recipient2, 35),
                new Call(recipient1, 411)
        ));

        //when
        List<Call> calls = callServiceSpy.getCallsByRecipient(recipient1);

        //then
        assertThat(calls.size(), equalTo(2));
        assertThat(calls.get(0).getRecipient(), sameInstance(calls.get(1).getRecipient()));
    }
} 
Obiekt typu Spy tworzymy przy użyciu metody spy(), analogicznie jak w przypadku tworzenia Mocka. Następnie „mockujemy” metodę getAllCalls(). Natomiast metodę getCallsByRecipient() wywołujemy bez „mockowania”.
Spy jest obiektem o działaniu bardzo podobnym do Mocka, ale dającym nieco większą elastyczność.

Konwencje tworzenia testów jednostkowych

Jak zapewne wszyscy wiemy, kod produkcyjny powinien być pisany tak, aby był przejrzysty i czytelny, łatwy w utrzymaniu i rozbudowie, zrozumiały nawet dla tych programistów, którzy nie uczestniczą w jego pisaniu itd. W pisaniu takiego kodu pomocne są np. zasady S.O.L.I.D., czy też wzorce projektowe.
Nie inaczej jest w przypadku pisania testów jednostkowych. One również powinny być jasne i zrozumiałe. W tym rozdziale zaprezentuję reguły i zasady, których stosowanie może pomóc w pisaniu lepszych testów.

Zasady FIRST

FIRST jest akronimem, którego pierwsze litery stanowią pięć cech jakimi powinny się odznaczać pisane przez nas testy jednostkowe:
  • Fast – testy powinny być szybkie. W związku z faktem, iż testy jednostkowe testują najmniejsze jednostki kodu, samych testów w dużych projektach mogą być tysiące. A każda zmiana w kodzie produkcyjnym wymaga uruchomienia wszystkich testów. Samo czekanie na wykonanie testów nie dość, że jest frustrujące, to może dodatkowo wybić programistę z jego rytmu pracy. Dlatego szybkie działanie testów jest tak ważne.
  • Independent (isolated) – testy powinny być niezależne, odizolowane od siebie nawzajem. Działanie lub wynik żadnego z testów nie może zależeć od żadnego innego testu. Testy nie mogą także być zależne od zewnętrznych serwisów czy też baz danych.
  • Repeatable – testy powinny być powtarzalne. Przy każdym uruchomieniu testy powinny zawsze dawać taki sam wynik. Testy nie mogą zawierać w sobie elementu losowości. Testy powinny być również powtarzalne na każdym środowisku, na którym zostaną uruchomione.
  • Self-validating – testy powinny dawać jednoznaczny wynik. Test albo kończy się pomyślnie albo kończy się niepowodzeniem. Tutaj nie ma miejsca na jakąkolwiek dodatkową interpretację. Dlaego też ważne jest, aby testy zakończone były asercjmi. One nam dają ostateczną odpowiedź na pytanie czy wykonanie danej akcji dla konkretnych założeń zakończy się powodzeniem.
  • Timely – testy powinny być tworzone w czasie pisania nowego kodu. Podczas dodawania nowej funkcjonalności (lub nawet przed jej dodaniem), należy od razu napisać do niej odpowiednie testy. Takie podejście daje nam większą pewność co do tego, że wprowadzane funkcjonalności zachowują się tak jak tego oczekujemy. Nie pisanie testów „na czas” może spowodować, że będziemy zapominać o dodawaniu testów do już wprowadzonych funkcjonalności, a to może generować trudne do odnalezienia błędy.

Zasady CORRECT

CORRECT, czyli następny akronim, określa, co powinniśmy testować oraz w jaki sposób. Zasady te mówią nam, jaki kod powinien zostać poddany testom oraz jakie przypadki brzegowe (nietypowe) powinniśmy przetestować:
  • Conformance – testy powinny sprawdzać zgodność kodu produkcyjnego z wymaganiami biznesowymi oraz zgodność kodu z wzorcami (np. sprawdzenie czy wprowadzony przez użytkownika adres e-mail jest zgodny z wzorcem adresu e-mail itp.).
  • Ordering – testy powinny sprawdzać kolejność przesyłania argumentów do metod oraz kolejność zwracanych parametrów. Jeżeli dla danej metody ważna jest kolejność przesłanych do niej argumentów (np. metoda wymaga posortowanej listy w argumencie), to należy przetestować jak ta metoda zachowa się w przypadku przesłania do niej argumentów nieposortowanych. Analogicznie wygląda sytuacja w przypadku parametrów zwracanych w wyniku działania funkcji.
  • Range – testy powinny sprawdzać zakres danego typu danych. Testy powinny sprawdzać, czy granice danego typu danych nie zostały przekroczone. Może się to tyczyć np. wyjścia poza zakres wartości dla zmiennej typu int, ale też każdej innej wartości zdefiniowanej przez pogramistę w kodzie produkcyjnym (np. wiek nie powinien być mniejszy niż 0).
  • Reference – testy powinny sprawdzać referencje do obiektów. Przykładowo, jeżeli dany obiekt posiada referencję do innego obiektu, to należy sprawdzić działanie tego obiektu w przypadku braku tej referencji.
    Z zasadą reference wiąże się jeszcze jeden aspekt. Tyczy się on samej metody testowej. Należy sprawdzić czy wszystkie warunki niezbędne do wykonania danego testu zostały spełnione i czy zostały odpowiednio oznaczone (sekcje given, when, then).
  • Existence – testy powinny sprawdzać istnienie argumentów. Zgodnie z tą zasadą, należy przetestować co się stanie, gdy jako argument do metody zostanie przesłany pusty obiekt, pusta tablica, pusty łańcuch znaków itp.
  • Cardinality – testy powinny sprawdzać liczność zbioru. Jeżeli dana metoda operuje na zbiorze elementów, należy sprawdzić, czy jego liczność jest zgodna z oczekiwaniami (czy jest pusty, czy posiada dokładnie 1 element, czy posiada wiele elementów itp.).
  • Time – testy powinny sprawdzać wielowątkowość. Wszędzie tam, gdzie aplikacja działa wielowątkowo, należy przetestować, czy jej działanie jest zgodne z oczekiwaniami.
    Zasada Time odnosi się także do kolejności wywołań metod. Jeżeli ważna jest kolejność, w jakiej metody są wywoływane, należy sprawdzić zachowanie aplikacji w przypadku nieprawidłowej kolejności.

Antywzorce testowe

Było już o tym, jak powinno się pisać testy. Warto też poznać niepoprawne praktyki w testach jednostkowych, aby wiedzieć czego unikać:
  • Dążenie do 100% pokrycia kodu testami. Testy jednostkowe powinny sprawdzać wszystkie wprowadzane przez nas funkcjonalności, ale nie ma sensu testować standardowe gettery, settery i konstruktory, które jedynie przypisują wartości.
  • Nieintuicyjne nazwy metod testowych. Jak już wspomniałem w pierwszym artykule, nazwy metod testowych powinny jednoznacznie określać co testują.
  • Długo działające testy. Jest to złamanie pierwszej zasady F.I.R.S.T.
  • Testy, które nie sprzątają po sobie. Jeżeli w ramach testów są np. tworzone pliki tekstowe, to po zakończeniu testów wszystkie te pliki powinny zostać usunięte.
  • Wiele niepowiązanych asercji w jednym teście. Czasem zachodzi konieczność, aby w jednym teście użyć kilku asercji. Należy pamiętać, że wszystkie z nich powinny testować jedną, tą samą funkcjonalność. Jeżeli wynik testu nie daje jednoznacznej odpowiedzi co poszło nie tak, to prawdopodobnie taki test powinien zostać rozbity na dwa lub więcej mniejszych.
  • Ignorowanie nie przechodzącego testu zamiast naprawy. Odkładanie w czasie naprawy błędu lub jego bagatelizowanie może się w późniejszym czasie zemścić na programiście. Wszystkie błędy, nawet te najmniejsze, powinny być naprawiane na bieżąco.
  • Traktowanie kodu testowego jak kodu drugiej kategorii. Ten przypadek jest ściśle powiązany z poprzednim. Bagatelizowanie testów może w późniejszym czasie spowodować, iż programista będzie musiał wracać do poprzednich etapów projektu, które niesłusznie uznał za zakończone.
  • Ręczne uruchamianie testów, brak automatyzacji. Konieczność ręcznego uruchamiania testów może spowodować, iż programista będzie czasem zapominał lub świadomie nie uruchamiał testów. A to może spowodować stopniowe namnażanie błędów. Testy powinny uruchamiać się automatycznie po każdej zmianie w kodze produkcyjnym.

Podsumowanie

W drugim artykule z serii o testach jednostkowych przedstawiłem obiekty, zwane atrapami, które umożliwiają nam symulowanie obiektów rzeczywistych. Następnie krótko opisałem najważniejsze zasady jak powinno się pisać testy jednostkowe i czego unikać. W ostatnim, trzecim artykule, przedstawię koncepcję, w której najpierw piszemy test, a dopiero później wprowadzamy funkcjonalność – Test Driven Development.

Znajdź mnie również na:

arrow