Testy bez I/O

Jeśli wolisz wideo to zapraszam na YT 🙂

Jednym z największych korzyści płynących z Architektury Portów i Adapterów jest możliwość tworzenia kilku adapterów do jednego portu. Możemy to robić, na przykład, na potrzeby testów. Dzięki takiemu podejściu możemy pisać szybkie testy bez dotykania I/O, które będą testowały logikę biznesową naszej aplikacji.

Odwrócenie zależności

Załóżmy, że mamy taką standardową sytuację: CustomerService wstrzykuje CustomerRepository.CustomerRepository dziedziczy po JpaRepository, czyli implementacja tego repozytorium jest realizowana przez Spring Data.W takim przypadku, żeby odwrócić zależność tak, żeby nasza logika biznesowa nie zależała od innych warstw, musimy odciąć nasz serwis od implementacji repozytorium, które pochodzi z warstwy dostępu do danych. Możemy to osiągnąć za pomocą interfejsu.

Czyli zamiast tworzyć interfejs, którego implementacja jest wykonywana przez jakąś bibliotekę, np. Spring Data, sami bierzemy na siebie odpowiedzialność za implementację tego interfejsu.

Wtedy nasz CustomerService może wstrzykiwać np. takie repozytorium:

public interface CustomerRepository {

    boolean existsByEmail(String email);

    Customer findById(UUID id);

    Customer save(Customer customer);

    boolean existsById(UUID id);
}

Co nam to dało?!
To, że teraz nasza logika biznesowa nie zależy od warstwy dostępu do danych. Oczywiście możemy to wykorzystać!

Adapter na potrzeby testów, czyli testy bez I/O

Na potrzeby testów możemy stworzyć osobny adapter (implementację) dla portu (repozytorium). Najłatwiej będzie zaimplementować repozytorium jako HashMap lub ConcurrentHashMap. Java pozwala łatwo dodawać takie obiekty do mapy i usuwać je stamtąd. Będzie działało o niebo szybciej niż korzystanie ze zwykłej bazy i ładownie kontekstu Springa 🙂

public class InMemoryCustomerRepository implements CustomerRepository {

    private final Map<UUID, Customer> store = new ConcurrentHashMap<>();

    @Override
    public boolean existsByEmail(final String email) {
        return store.values().stream()
                .anyMatch(c -> c.getEmail().equals(email));
    }

    @Override
    public Customer findById(final UUID id) {
        return store.get(id);
    }

    @Override
    public Customer save(final Customer customer) {
        store.put(customer.getId(), customer);
        return customer;
    }

    @Override
    public boolean existsById(final UUID id) {
        return store.containsKey(id);
    }

    public void deleteAll() {
        store.clear();
    }
}

Oczywiście, w CustomerService nic się nie zmienia, a my możemy sobie tworzyć ten obiekt w testach bez potrzeby ładowania kontekstu Springa, przy pomocy konstruktora. Dlatego właśnie zaleca się wstrzykiwanie przez konstruktor!

class CustomerServiceTest {

    private final InMemoryCustomerRepository customerRepository = new InMemoryCustomerRepository();
    private final CustomerService customerService = new CustomerService(customerRepository);
//...

Dalej testy będą takie same jakbyśmy wstrzykiwali nasz CustomerService, bo przecież mamy jego instancje, tylko że sami ją tworzymy przy pomocy konstruktora.

Adapter w kodzie źródłowym

Oczywiście, później w kodzie źródłowym utworzymy kolejną implementację dla tego portu, czyli zrobimy kolejny adapter. Co ważne, jednak można to odroczyć w czasie i na początku napisać i przetestować logikę biznesową. A dopiero potem podpiąć bazę danych itp.

W kodzie źródłowym repozytorium prawdopodobnie będzie już korzystać z bazy danych, a my możemy korzystać z dobrodziejstw, jakie oferują nasze frameworki. Warto zwrócić uwagę, że implementujemy tylko te metody, które faktycznie będą używane w naszym kodzie źródłowym, a nie wszystkie. Jeśli nie pozwalamy na usuwanie zasobów, to nie mamy w repozytorium metody deleteAll(), w przeciwieństwie do tego, jakbyśmy korzystali z JpaRepository. Oczywiście, w adapterze, który powstał na potrzeby testów, taką metodę możemy sobie zaimplementować.

Jak zaimplementować w kodzie źródłowym to repozytorium, aby nie tracić korzyści płynących z naszych frameworków? Możemy wewnątrz naszej implementacji repozytorium stworzyć interfejs, który będzie implementował JpaRepository i wstrzyknąć go do naszej implementacji.

@Repository
class SqlCustomerRepository implements CustomerRepository {

    private final JpaCustomerRepository jpaCustomerRepository;

    SqlCustomerRepository(JpaCustomerRepository jpaCustomerRepository) {
        this.jpaCustomerRepository = jpaCustomerRepository;
    }

    @Override
    public boolean existsByEmail(final String email) {
        return jpaCustomerRepository.existsByEmail(email);
    }

    @Override
    public Customer findById(final UUID id) {
        return jpaCustomerRepository.findById(id)
                .orElseThrow(() -> new CustomerNotFoundException(id));
    }

    @Override
    public Customer save(final Customer customer) {
        return jpaCustomerRepository.save(customer);
    }

    @Override
    public boolean existsById(final UUID id) {
        return jpaCustomerRepository.existsById(id);
    }
}

@Repository
interface JpaCustomerRepository extends JpaRepository<Customer, UUID> {

    boolean existsByEmail(String email);
}

Podsumowanie

Jeśli nasza aplikacja ma bogatą logikę biznesową, warto taką logikę odseparować od pozostałych warstw naszej aplikacji. Pozwala to w łatwy sposób testować serce naszej aplikacji, dzięki temu, że możemy na potrzeby testów tworzyć osobne adaptery. Sprawia to, że nasze testy są o wiele szybsze i nie potrzebują kontekstu Springa. Co więcej, testy bez dotykania I/O i ładowania kotenstu Springa zachęcają do korzystania z Test Driven Development, czyli pisania pierwszych testów, a potem kodu źródłowego.

Oczywiście, w powyższym wpisie pokazałem, jak można odciąć serwis od repozytorium, bo to najczęstszy problem. Jednak możemy w ten sposób postępować z każdą klasą, która wstrzykuje jakiś bean dotykający z I/O. Pamiętajmy, że porty i adaptery to tylko idea!

Bądź na bieżąco!

Zostaw swój e-mail, a raz na jakiś czas wyśle Ci info o nowych postach na blogu. Tylko wartościowe e-mail!

Logo github

5 thoughts on “Testy bez I/O

  1. Jak sobie przejrzałem kod z w/w kursu to jest dokładnie jak to opisujesz w swoich wpisach, z tą różnicą, że w pakiecie z testami klasa InMemory… została ustawiona jako zagnieżdżona.

    Natomiast co mnie zaskoczyło w przytoczonym powyżej przykładzie to podwójne dziedziczenie, które nieczęsto widywane jest w Javie. Jeśli bym zrobił tak:
    – interface SqlTaskRepository implements TaskRepository, JpaRepository
    to ten nowy interfejs będzie zawierał cały zbiór metod z obu implementowanych interfejsów.
    Natomiast jeśli zrobię to tak:
    – interface SqlTaskRepository extends TaskRepository, JpaRepository
    to najwidoczniej nowy interfejs będzie zawierał TYLKO część wspólną obu zbiorów.

    Nie jestem na 100% pewien, że tak właśnie o działa, ale na to by wychodziło.

    1. W Javie klasa nie może dziedziczyć po dwóch klasach.
      Interfejs może dziedziczyć z wielu interfejsów.
      Klasa może implementować wiele interfejsów.
      Tak, nie można zrobić: interface SqlTaskRepository implements TaskRepository, JpaRepository
      Interfejs nie może implementować innych interfejsów.

      Możesz to zrobić tak: class SqlTaskRepository implements TaskRepository, JpaRepository
      Wtedy SqlTaskRepository będzie implementował wszystkie metody z obu interfejsów, choć nie widzę potrzeby jawnego samodzielnego implementowania interfejsu JpaRepository.

      Jeśli zrobisz tak:
      interface SqlTaskRepository extends TaskRepository, JpaRepository
      to Twój interfejs SqlTaskRepository rozszerza oba interfejsy TaskRepository i JpaRepository, co oznacza, że zawiera ich metody.
      Klasa implementująca interfejs SqlTaskRepository będzie musiała dostarczyć implementacje dla wszystkich metod zdefiniowanych zarówno w TaskRepository, jak i w JpaRepository, co też trochę mija się z celem, bo możemy dostać darmową impmentacje JpaRepository od Spring Data.

      1. Dzięki za wytłumaczenie!

        Faktycznie, jeszcze mi się nie zdarzyło, żebym w swoich projektach próbował zrobić interfejs implementujący 2 inne interfejsy i wziąłem za pewnik, że tak się da.

  2. Chyba widziałem coś podobnego na kursie Springa M. Chronstowskiego na Udemy. W skrócie – najpierw stworzył repo „public interface TaskRepository” z metodami z których będzie korzystał, niepodłączone do Springa, a potem:
    @Repository
    interface SqlTaskRepository extends TaskRepository, JpaRepository

    I jakimś cudem to SqlTaskRepository, które niby dziedziczy po JpaRepository z milionem różnych metod, ma dostęp tylko do metod zadeklarowanych w TaskRepository. Wciąż próbuję rozkminić co tam się wydarzyło.

    1. Siemanko, dzięki za komentarz!
      Faktycznie może wydawać się to nieoczywiste 😄.
      Nie wiem, jak dokładnie wyglądała sytuacja w tym kursie, ale tutaj, jeśli byśmy stworzyli interfejs TaskRepository, to on po niczym nie dziedziczy.
      Następnie tworzymy implementację tego interfejsu np. SqlTaskRepository. Ta implementacja może wstrzykiwać inny interfejs, który korzysta z JpaRepository. Dzięki czemu możemy korzystać z „dobroci” dawanych przez Spring Data (wiem, że brzmi zagmatwanie) 😀
      Czyli SqlTaskRepository jest klasą, nie interfejsem, i implementuje TaskRepository.
      W kodzie źródłowym wszędzie wstrzykujemy TaskRepository.
      Dzięki temu w kodzie źródłowym mamy tylko i wyłącznie dostęp do metod, których potrzebujemy, bo definiujemy je w TaskRepository, a na potrzeby testów możemy tworzyć inną implementację TaskRepository.

      Na filmiku YT to implementuję dla aplikacji „lojalnościowej”:
      Testy bez dotykania I/O: Benefit architektury portów i adapterów

      A na Git mam projekt gdzie to implementowałem dla innej domeny:
      Interfejs
      Implantacja w kodzie źródłowym
      Implantacja w testach

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Zacznij wpisywać wyszukiwane hasło powyżej i naciśnij Enter, aby wyszukać. Naciśnij ESC, aby anulować.

Do góry