Testy jednostkowe – Stuby

Stuby są wykorzystywane w sytuacji, gdy w testowanej klasie występują pewne zależności. Działanie tych zależności należy obsłużyć, ale problem pojawia się, jeśli nie mamy do nich lub do ich metod bezpośredniego dostępu. Właśnie w tych scenariuszach mogą nam pomóc stuby lub mocki.

Ten wpis jest pierwszą częścią miniserii o stubach oraz mockach. Poznamy w nim wady oraz zalety stubów, a także ich ogólną charakterystykę i zastosowanie. W kolejnej części – bliżej przyglądamy się mockom.

Scenariusz testowy

Naszą bazą kodową, którą chcielibyśmy przetestować, jest aplikacja do zamawiania jedzenia online. Składa się z prostych klas takich jak Account, Order, Cart czy też Meal. W tym wpisie operować będziemy głównie na klasie Account:

class Account {

    private boolean active;
    private Address defaultDeliveryAddress;

    Account() {
        this.active = false;
    }

    Account(Address defaultDeliveryAddress) {
        this.defaultDeliveryAddress = defaultDeliveryAddress;
        if(defaultDeliveryAddress != null) {
            activate();
        } else {
            this.active = false;
        }
    }

    void activate() {
        this.active = true;
    }

    boolean isActive() {
        return this.active;
    }

    Address getDefaultDeliveryAddress() {
        return defaultDeliveryAddress;
    }

    void setDefaultDeliveryAddress(Address defaultDeliveryAddress) {
        this.defaultDeliveryAddress = defaultDeliveryAddress;
    }

}

Jest to prosta klasa typu POJO. Jedyną porcją logiki są konstruktory. Jeśli przy tworzeniu obiektu typu Account skorzystamy z konstruktora bezargumentowego lub też w konstruktorze oczekującym obiektu typu Address przekażemy wartość null, to wtedy utworzone konto będzie nieaktywne, a więc metoda isActive będzie zwracała wartość false. W przeciwnym razie oczywiście zwrócona będzie wartość true.

Nowa funkcjonalność

Teraz przyszedł czas, aby nieco rozbudować naszą aplikację. Chcemy dodać do niej nową funkcjonalność – możliwość pobrania listy klientów z bazy danych, tak aby wysłać im na przykład jakieś wiadomości promocyjne.

Aby to zrobić najpierw tworzymy nowy interfejs, który nazwiemy AccountRepository:

public interface AccountRepository {

    List<Account> getAllAccounts();

}

W interfejsie umieściliśmy deklarację jednej metody – getAllAccounts, która ma oczywiście służyć do pobrania kont wszystkich klientów.

Czas na implementację

Kolejnym naturalnym krokiem byłoby utworzenie implementacji powyższego interfejsu. Jednak aby to zrobić rzetelnie, należałoby najpierw przygotować całą konfigurację połączenia z bazą danych, co z kolei zajęłoby sporo czasu, a przecież nie to jest głównym tematem tego wpisu.


Jeśli chcesz prześledzić zmiany krok po kroku w formie wideo, to zapraszamy na nasz kanał na YouTube.


Można też na to spojrzeć w taki sposób, że w naszej aplikacji mamy dostęp tylko i wyłącznie do powyższej metody z interfejsu, a nie do samej jej implementacji. Może być przecież tak, że implementacją zajmuje się inny zespół albo też dane które nas interesują, możemy otrzymywać z jakiegoś innego, zewnętrznego systemu lub API. Załóżmy więc, że nie mamy bezpośredniego dostępu do metody implementującej. Zobaczmy więc, czy mimo takiej niedogodności będziemy w stanie odpowiednio przetestować interesujący nas kod.

Do wyciągania informacji o kontach potrzebny nam będzie serwis. Utworzymy więc stosowną klasę serwisową: AccountService, w którym będziemy mieli jedną zależność. Tą zależnością będzie oczywiście AccountRepository, więc dodajemy ją w konstruktorze:

class AccountService {

    private AccountRepository accountRepository;

    AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }


}

W powyższej klasie chcielibyśmy umieścić metodę, która będzie zwracała tylko konta aktywne. Nazwiemy ją getActiveAccounts:

List<Account> getAllActiveAccounts() {
    return accountRepository.getAllAccounts().stream()
            .filter(Account::isActive)
            .collect(Collectors.toList());
}

Jak widać metoda ta opiera się na danych, które ma zwracać metoda getAllAccounts, będąca implementacją metody z interfejsu AccountRepository. Metoda pobiera konta użytkowników, następnie filtruje je korzystając z metody isActive z klasy Account.

Klasa testowa

Nasz kod jest już gotowy do przetestowania. Tworzymy więc klasę testową AccountServiceStubTest, a w niej metodę getActiveAccounts:

@Test
void getAllActiveAccounts() {

    //given
    AccountService accountService = new AccountService();

W sekcji given chcieliśmy utworzyć obiekt klasy AccountService, ale pojawił się problem, ponieważ w konstruktorze musimy podać klasę, która implementuje interfejs AccountRepository, a my nie mamy instancji takiej klasy.

I właśnie w takiej sytuacji mogą nam pomóc mocki lub stuby. Zajmijmy się zatem stubami.

Czym są stuby?

Stuby to przykładowe implementacje jakiegoś kodu, którego zachowanie chcemy przetestować. Jeśli – tak jak w naszym przykładzie – nie mamy dostępu do prawdziwej metody, która będzie nam zwracała dane, to sami powinniśmy sobie taką metodę utworzyć. Musimy napisać ją w taki sposób, aby zwracała nam zestaw przykładowych danych.

Utwórzmy sobie zatem takiego stuba. Zaczniemy od stworzenia nowej klasy, a jako że stuby są nierozłącznie związane z testami jednostkowymi, to klasy stubowe najlepiej tworzyć w tej samej paczce, w której są normalne klasy testowe. Naszą klasę stubową nazwiemy dość nieoryginalnie – AccountRepositoryStub. Klasa ta powinna implementować interfejs AccountRepository i nadpisywać metodę getAllAccounts:

public class AccountRepositoryStub implements AccountRepository {

    @Override
    public List<Account> getAllAccounts() {
        Address address1 = new Address("Kwiatowa", "33/5");
        Account account1 = new Account(address1);

        Account account2 = new Account();

        Address address2 = new Address("Piekarska", "12b");
        Account account3 = new Account(address2);

        return Arrays.asList(account1, account2, account3);
    }

}

Implementacja tej metody jest bardzo prosta: tworzymy nowe konta użytkowników i zwracamy je w postaci listy – zgodnie z deklaracją metody getAllAccounts.

Teraz możemy wrócić do naszego testu i w konstruktorze serwisu przekazać naszego stuba:

@Test
void getAllActiveAccounts() {

    //given
    AccountRepository accountRepositoryStub = new AccountRepositoryStub();
    AccountService accountService = new AccountService(accountRepositoryStub);

A skoro wiemy jakie dane zwraca nasze stub, to wiemy też jaką asercję należy napisać, żeby test przeszedł pozytywnie. Zatem cały test prezentuje się tak:

@Test
void getAllActiveAccounts() {

    //given
    AccountRepository accountRepositoryStub = new AccountRepositoryStub();
    AccountService accountService = new AccountService(accountRepositoryStub);

    //when
    List<Account> accountList = accountService.getAllActiveAccounts();

    //then
    assertThat(accountList, hasSize(2));

}

Zakładaliśmy, że zwrócona lista powinna mieć 2 elementy, ponieważ dwa utworzone przez nas w metodzie stubowej konta miały w konstruktorze przekazany adres, a więc tylko te dwa konta są aktywne.

W ten właśnie sposób działają stuby.

Problemy stubowe

Jednak tutaj mamy tylko jeden scenariusz testowy, bo stub zwraca nam jeden, konkretny zestaw danych. A  co jeśli teraz chcielibyśmy przeprowadzić inny test? Co jeśli na przykład chcielibyśmy sprawdzić co będzie, jeśli z repozytorium nie zwrócone zostaną żadne konta aktywne?

W tej chwili nasz stub zwraca 2 konta aktywne i nie można w nim dodać kolejnej metody, ponieważ tylko i wyłącznie ta jedna, jedyna metoda może być implementacją metody z interfejsu. Zatem żeby przetestować inny scenariusz, trzeba by dodać kolejną klasę stubową implementującą interfejs AccountRepository. Następnie w tejże klasie utworzyć utworzyć kolejną metodę która zwracałaby inny zestaw danych.

I jak łatwo się domyślić wkrótce tych klas i metod stubowych mogłoby się zrobić bardzo dużo. A im bardziej skomplikowana metoda, tym więcej klas, i metod.

Jest jeszcze jedna kwestia. A co jeśli do interfejsu dodana zostanie kolejna metoda? Wtedy trzeba by ją implementować we wszystkich klasach stubowych, żeby móc je odpowiednio przetestować i uniknąć błędu kompilacji. Bardzo szybko może się zatem okazać, że klasy stubowe są bardzo ciężkie w utrzymaniu.

Podsumowanie

Dla bardzo prostych klas, metod i przykładów, w których jesteśmy pewni, że nie będą się rozrastać, stuby spełniają swoje zadanie. Jednak przy większej liczbie warunków testowych oraz przy możliwym rozroście interfejsów, są one niestety kiepskim rozwiązaniem. W tych sytuacjach znacznie lepiej skorzystać z mocków, którymi zajmiemy się w kolejnej części tej miniserii.


Podziel się tym wpisem:

1 komentarz do wpisu “Testy jednostkowe – Stuby”

Dodaj komentarz