Czerwiec 22, 2019

Czy dependency injection ułatwia testowanie?

Seria o testach jednostkowych

  1. Wstęp do testów jednostkowych
  2. Test Driven Development w praktyce
  3. Czy dependency injection ułatwia testowanie?
  4. Mocki w testach jednostkowych
  5. Mockito spy

 

Dependency Injection

W kolejnej części serii o testach jednostkowych nadchodzi czas na odpowiedzenie sobie na jedno zajebiście, ale to zajebiście ważne pytanie:

Czy Dependency Injection ułatwia testowanie?

Zanim w ogóle będzie można odpowiedź na to pytanie musimy zacząć od początku – czym w ogóle jest dependency injection?

Na wstępie zaznaczę, że celem tego wpisu nie jest pokazanie Ci jak DI jest fajne i jak jego użycie może być przydatne – zarysuję Ci tylko na czym polega, jeśli chcesz zrozumieć jak działa dependency injection to wystarczy, że przeczytasz mój wpis na temat wstrzykiwania.

Dependency Injection jest wzorcem projektowym, dzięki któremu możemy w łatwy sposób decydować jaką implementację przekazujemy do konkretnego miejsca (czyt. klasy) – możemy to robić przez konstruktor lub setter. DI zawdzięczamy bardzo ważną rzecz – to ona zapewnia, że jedna klasa nie wie o drugiej, zależności w naszej aplikacji są tzw. luźno powiązane.

Mówiąc luźno myślę o tym, że w każdym momencie możemy przestać używać konkretnej zależności w danej klasie i w bardzo łatwy sposób podmienić ją na inną – wystarczy, że ma ten sam wspólny interfejs co klasa poprzednia.

Zanim przejdziesz dalej, proszę Cię, abyś zapamiętał ostatnie słowa:

[…]i w bardzo łatwy sposób podmienić ją na inną – wystarczy, że ma ten sam wspólny interfejs co klasa poprzednia.

Do tych słów jeszcze wrócimy w dalszej części artykułu, a na ten moment koniec teorii i lecimy na warsztat!

Nowa funkcjonalność

Jest to trzeci wpis z tej serii – niestety nie mam pewności, że czytałeś dwa poprzednie wpisy. Możliwe, że dwa poprzednie Cię w ogóle nie zainteresowały, dlatego przytoczę kody klas, które były używane w pierwszym artykule. Na bazie tego kodu będziemy rozbudowywać nową funkcjonalność – czyli kolejne wymagania klienta.

Kilka dni temu zjawił się u mnie nasz klient – Pablo – z nowymi wymaganiami co do aplikacji. Aktualna aplikacja umożliwia stworzenie użytkownika – podając przy tym login oraz hasło, które są wykorzystywane w innej części ogromnego systemu (ale to nie o tym).

Pablo zażyczył sobie, aby podczas rejestracji do użytkownika był przypisywany do niego kraj z jakiego pochodzi – nie ma to być zwykły ciąg znaków, ale kilka informacji na temat danego kraju, które należy uzyskać z innego webserwisu. Dane z drugiego serwisu należy uzyskać poprzez zapytanie HTTP pod wskazany adres dodając do niego nazwę kraju. W przypadku, gdy serwer nie odpowiada lub wystąpił jakikolwiek błąd związany z komunikacją należy użytkownikowy przypisać domyślny kraj – Wielką Brytanię.

Powyższy akapit przedstawia wymagania funkcjonalne naszego klienta – naszym zadaniem będzie to zaimplementować. Zapomniałem wspomnieć, że musimy do tego napisać testy – cóż, tak sobie zażyczył zleceniodawca. Teraz przedstawię Ci kod, który aktualnie mamy.

Jeśli tylko chcesz kod, który pokazuję znajdziesz w tym repozytorium.

Wszystko jest oparte o prostą klasę User przechowującą informację o użytkowniku:

import java.util.Objects;

public class User {
    private Long id;
    private String login;
    private String password;

    public User(String login, String password) {
        this.login = login;
        this.password = password;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    public String getLogin() {
        return login;
    }

    public String getPassword() {
        return password;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", login='" + login + '\'' +
                ", password='" + password + '\'' +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) &&
                Objects.equals(login, user.login) &&
                Objects.equals(password, user.password);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login, password);
    }
}

A większość akcji rozgrywa się w klasie UserService, dzięki której możemy dodawać i usuwać użytkowników.

public class UserService {
    private final UserDao userDao = new UserDao();
    private final UserValidator userValidator = new UserValidator();

    public User createUser(User user) {
        userValidator.validate(user);

        return userDao.create(user);
    }

    public void removeUser(Long userId) {
        boolean result = userDao.delete(userId);

        if (!result) {
            throw new RuntimeException("User with id: " + userId + " doesn't exist.");
        }
    }
}

W powyższym serwisie wykorzystujemy dwie klasy – pierwsza z nich to UserDao – jest ona odpowiedzialna za komunikację z bazą danych (a dokładniej z jej imitacją)

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class UserDao {
    private final Map<Long, User> users = new HashMap<>();
    private Long userId = 0L;

    public User create(User user) {
        Long id = userId;
        userId += 1;

        user.setId(id);
        users.put(id, user);
        return user;
    }

    public Optional<User> getById(Long id) {
        return Optional.ofNullable(users.get(id));
    }

    public boolean delete(Long id) {
        return Optional.ofNullable(users.remove(id)).isPresent();
    }
}

Zaś druga klasa to UserValidator, która ma za zadanie sprawdzić poprawność danych na podstawowym poziomie:

public class UserValidator {
    public void validate(User user) {
        if (isPasswordTooShort(user.getPassword())) {
            throw new RuntimeException("Password is too short.");
        }

        if (isLoginTooShort(user.getLogin())) {
            throw new RuntimeException("Login is too short.");
        }
    }

    private boolean isLoginTooShort(String login) {
        return login.length() < 3;
    }

    private boolean isPasswordTooShort(String password) {
        return password.length() < 6;
    }
}

Mam nadzieję, że kod jest dla Ciebie zrozumiały – jeśli nie, to proszę wróć do pierwszego wpisu z tej serii, abyś w pełni mógł zrozumieć to o czym teraz będę mówił.

Jeśli wszystko jest dla Ciebie klarowne to możemy przejść dalej czyli…

TDD – jak to leciało?

…do napisania testów, a nie już konkretnego kodu! – oczywiście mam tutaj na myśli koncepcję Test Driven Development, którą opisywałem w poprzednim wpisieMust have knowledge zanim przejdziemy dalej.

Jeśli TDD Cię nie potrafi już zaskoczyć to jesteś gotowy iść dalej – wgłąb kodu i testów!

0. Stworzenie sygnatur metod w celu skompilowania się kodu i uruchomienia testów

Póki co sygnatura metody createUser wygląda tak:

User createUser(User user) {

Wprowadzimy tutaj prostą zmianę – wprowadzimy tzw. klasę DTO (Data Transfer Object), czyli obiekt, który będzie przychodził z warstwy użytkownika. Użyjemy go do zbudowania obiektu typu User, który zostanie umieszczony w naszej bazie danych.

Często stosuje się DTO, aby móc sterować tym czego wymagamy np. w Restowym API aplikacji, to co nasze API zwraca np. nie chcemy zwracać 100% informacji lub chcemy połączyć wiele informacji z kilku obiektów.

Klasa UserDTO prezentuje się tak:

public class UserDTO {
    private final String login;
    private final String password;
    private final String country;

    public UserDTO(String login, String password, String country) {
        this.login = login;
        this.password = password;
        this.country = country;
    }

    public String getLogin() {
        return login;
    }

    public String getPassword() {
        return password;
    }

    public String getCountry() {
        return country;
    }
}

Klasa DTO mówi czego wymagamy od klienta – aktualnie nasza aplikacja wymaga podania loginu oraz hasła, my od razu dodamy konieczność podania nazwy kraju.

Mając już taką klasę możemy zmienić sygnaturę metody createUser na:

User createUser(UserDTO userDTO) {

Można było oczywiście zmienić sygnaturę w inny sposób – wystarczyło dodać kolejny parametr w metodzie, który przyjmowałby nazwę kraju. Ja jednak wolę podejście DTO, gdzie wszystko co konieczne mamy zamknięte w jednym obiekcie.

Aby kod się mógł skompilować musimy zmienić również implementację metody z:

public User createUser(UserDTO user) {
    userValidator.validate(user);

    return userDao.create(user);
}

Np. na taką: (metoda validate musi przyjmować też UserDTO)

User createUser(UserDTO userDTO) {
    userValidator.validate(userDTO);

    User user = new User(userDTO.getLogin(), userDTO.getPassword(), new Country());

    return userDao.create(user);
}

Nie skupiajmy się na samej logice metody – kod jak na razie ma się nam skompilować.

Zanim będziemy mogli przejść do pisania testów musimy najpierw zdefiniować model państwa, który będziemy odczytywać z drugiego serwisu – owa klasa wygląda tak:

import java.util.Objects;

public class Country {
    private String capital;
    private Long population;
    private String nativeName;

    public Country() {
        
    }

    Country(String capital, Long population, String nativeName) {
        this.capital = capital;
        this.population = population;
        this.nativeName = nativeName;
    }

    @Override
    public String toString() {
        return "Country{" +
                "capital='" + capital + '\'' +
                ", population=" + population +
                ", nativeName='" + nativeName + '\'' +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Country country = (Country) o;
        return Objects.equals(capital, country.capital) &&
                Objects.equals(population, country.population) &&
                Objects.equals(nativeName, country.nativeName);
    }

    @Override
    public int hashCode() {
        return Objects.hash(capital, population, nativeName);
    }

}

Nic skomplikowanego – następnie musimy zaktualizować klasę User, aby była w stanie przechowywać państwo:

import java.util.Objects;

public class User {
    private Long id;
    private String login;
    private String password;
    private Country country;


    User(String login, String password, Country country) {
        this.login = login;
        this.password = password;
        this.country = country;
    }

    void setId(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    public String getLogin() {
        return login;
    }

    public String getPassword() {
        return password;
    }

    public Country getCountry() {
        return country;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", login='" + login + '\'' +
                ", password='" + password + '\'' +
                ", country=" + country +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) &&
                Objects.equals(login, user.login) &&
                Objects.equals(password, user.password) &&
                Objects.equals(country, user.country);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, login, password, country);
    }
}

1. Napisz (popraw) testy

O ile w poprzednim artykule pokazywałem przykład, w którym to my pisaliśmy pierwsze testy dla klasy to w tym przypadku jest trochę inaczej. Aktualnie mamy napisanych już kilka testów, które prezentują się tak:

import org.junit.Assert;
import org.junit.Test;

public class UserServiceTest {

    @Test
    public void shouldCorrectAddNewUser() {
        // given
        UserService userService = new UserService();
        User user = new User("pablo", "pablo123");
        User expectedUser = new User("pablo", "pablo123");
        expectedUser.setId(0L);

        // when
        User resultUser = userService.createUser(user);

        // then
        Assert.assertEquals(expectedUser, resultUser);
    }

    @Test(expected = RuntimeException.class)
    public void shouldThrowLoginExceptionWhileCreatingNewUser() {
        // given
        UserService userService = new UserService();
        User user = new User("pa", "pablo123");

        // when
        userService.createUser(user);
    }

    @Test(expected = RuntimeException.class)
    public void shouldThrowPasswordExceptionWhileCreatingNewUser() {
        // given
        UserService userService = new UserService();
        User user = new User("pablo", "pass");

        // when
        userService.createUser(user);
    }

    @Test
    public void shouldCorrectRemoveUser() {
        // given
        UserService userService = new UserService();
        User user = new User("pablo", "pablo123");
        userService.createUser(user);


        //when
        userService.removeUser(0L);
    }
    @Test(expected = RuntimeException.class)
    public void shouldThrowExceptionWhenUserDoesntExistWhileRemoving() {
        // given
        UserService userService = new UserService();

        //when
        userService.removeUser(1L);
    }

}

Nasze testy na pewno nie uruchomią się poprawnie, ponieważ sygnatura metody się zmieniła. Zacznijmy od poprawienia parametrów wejściowych do metody createUser.

import org.junit.Assert;
import org.junit.Test;

public class UserServiceTest {
    private final String POLAND = "poland";
    private final Country POLAND_COUNTRY = new Country("Warsaw", 38437239L, "Polska");

    @Test
    public void shouldCorrectAddNewUser() {
        // given
        UserService userService = new UserService();
        UserDTO userDTO = new UserDTO("pablo", "pablo123", POLAND);
        User expectedUser = new User("pablo", "pablo123", POLAND_COUNTRY);
        expectedUser.setId(0L);

        // when
        User resultUser = userService.createUser(userDTO);

        // then
        Assert.assertEquals(expectedUser, resultUser);
    }

    @Test(expected = RuntimeException.class)
    public void shouldThrowLoginExceptionWhileCreatingNewUser() {
        // given
        UserService userService = new UserService();
        UserDTO userDTO = new UserDTO("pa", "pablo123", POLAND);

        // when
        userService.createUser(userDTO);
    }

    @Test(expected = RuntimeException.class)
    public void shouldThrowPasswordExceptionWhileCreatingNewUser() {
        // given
        UserService userService = new UserService();
        UserDTO userDTO = new UserDTO("pablo", "pass", POLAND);

        // when
        userService.createUser(userDTO);
    }

    @Test
    public void shouldCorrectRemoveUser() {
        // given
        UserService userService = new UserService();
        UserDTO userDTO = new UserDTO("pablo", "pablo123", POLAND);
        userService.createUser(userDTO);


        //when
        userService.removeUser(0L);
    }

    @Test(expected = RuntimeException.class)
    public void shouldThrowExceptionWhenUserDoesntExistWhileRemoving() {
        // given
        UserService userService = new UserService();

        //when
        userService.removeUser(1L);
    }

}

Zmienna POLAND_COUNTRY przechowuje informacje, które zwraca aktualnie RestAPI dla Polski, z którego zaraz skorzystamy.

Skoro stare testy są już poprawione to czas napisać nowy test obejmujący przypadek, gdy komunikacja zewnętrznym serwisem zawiedzie.

@Test
    public void shouldAddDefaultCountryToUserWhenRestApiDoesntReply() {
        // given
        UserService userService = new UserService();

        UserDTO userDTO = new UserDTO("pablo", "pablo123", POLAND);
        User expectedUser = new User("pablo", "pablo123", UserService.DEFAULT_COUNTRY);
        expectedUser.setId(0L);

        // when
        User resultUser = userService.createUser(userDTO);

        // then
        Assert.assertEquals(expectedUser, resultUser);
    }

DEFAULT_COUNTRY jest obiektem typu Country, który zawiera informację o domyślnym kraju – czyli według wymagań klienta chodzi tu o Wielką Brytanię.

public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");

2. Uruchom testy

Po uruchomieniu testów powinniśmy zobaczyć, że dwa testy się nie powiodły.

3. Napisz implementację metod

Stało się tak, ponieważ nasz kod jeszcze nie uwzględnia komunikacji z zewnętrznym webserwisem – czas to zmienić!

Komunikacja z webserwisem

W celu ułatwienia sobie pracy podczas komunikowania się z serwisem dodałem trzy zależności do projektu:

<!-- https://mvnrepository.com/artifact/org.springframework/spring-web -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>5.1.8.RELEASE</version>
    </dependency>

    <!-- https://mvnrepository.com/artifact/org.springframework/spring-core -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>5.1.8.RELEASE</version>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.9.8</version>
    </dependency>

Dzięki nim będę mógł korzystać z dobrodziejstw Springa – RestTemplate oraz ParameterizedTypeReference – czyli klas, które ułatwiają tworzenie zapytań Http.

Z zgodnie z zasadą single responsibility principle tworzymy nową klasę CountryService, która będzie odpowiedzialna za komunikację:

import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

class CountryService {
    private static final String COUNTRY_REST_API_URL = "https://restcountries.eu/rest/v2/name/";
    private final RestTemplate restTemplate = new RestTemplate();

    public Country getCountryByName(String name) {
        ResponseEntity<List<Country>> countries = restTemplate.exchange(
                buildRequestURL(name),
                HttpMethod.GET,
                null,
                new ParameterizedTypeReference<List<Country>>() {
                });

        return Optional.ofNullable(countries.getBody())
                .orElse(Collections.emptyList()).stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("Can't find country for name: " + name));
    }

    private String buildRequestURL(String requestParam) {
        return COUNTRY_REST_API_URL + requestParam;
    }
}

i dodajemy ją jako pole w klasie UserService.

public class UserService {
    private final UserDao userDao = new UserDao();
    private final UserValidator userValidator = new UserValidator();
    private final CountryService countryService = new CountryService();
    ...
}

Wykorzystanie takiej klasy jest bardzo proste – podajemy Stringa, otrzymujemy Country. Przejdźmy do UserService i użyjmy tego serwisu:

class UserService {
    public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");
    private final UserDao userDao = new UserDao();
    private final UserValidator userValidator = new UserValidator();
    private final CountryService countryService = new CountryService();

    User createUser(UserDTO userDTO) {
        userValidator.validate(userDTO);

        Country country = getCountry(userDTO.getCountry());

        User user = convertToUser(userDTO, country);

        return userDao.create(user);
    }

    private Country getCountry(String country) {
        try {
            return countryService.getCountryByName(country);
        } catch (Exception e) {
            System.err.println("Can't fetch country by name: " + country + " due to: " + e.getMessage());
            return DEFAULT_COUNTRY;
        }
    }

    private User convertToUser(UserDTO userDTO, Country country) {
        return new User(userDTO.getLogin(), userDTO.getPassword(), country);
    }

    void removeUser(Long userId) {
        boolean result = userDao.delete(userId);

        if (!result) {
            throw new RuntimeException("User with id: " + userId + " doesn't exist.");
        }
    }
}

Wydobycie kraju z zewnętrznego serwisu wyrzuciłem do osobnej metody, aby nie „brudzić” tej publicznej – chciałem, aby została czytelna dla osób dołączających do projektu.

W osobnej metodzie stworzyłem blok try – catch, w celu zwrócenia domyślnego kraju, gdy serwis rzuci nam jakimkolwiek wyjątkiem.

Po tym wszystkie przypisuję wydobyty kraj do użytkownika i zapisuję tak jak wcześniej do bazy danych. Sprawdźmy czy po napisaniu tej implementacji testy przejdą pomyślnie.

No i okazuje się, że jeden test nie przeszedł – ten odpowiedzialny za sytuację, gdy komunikacja zawiedzie. No i nic dziwnego, przecież…

Zewnętrzny serwis ciągle działa, więc jak to należy przetestować?

Houston, mamy problem

Podczas implementacji nowej funkcjonalności, a bardziej w czasie, gdy ją testowaliśmy napotkaliśmy pewien problem – jak możemy zasymulować sytuację, której oczekujemy w przypadku testowym?

Jak możemy to zrobić, gdy nie mamy kontroli nad zewnętrznym serwisem, a owy serwis ma się bardzo dobrze i nie ma zamiaru zaprzestać się z nami komunikować?

Nic się nie da zrobić – na szczęście tylko w przypadku, w którym aktualnie jesteśmy. Sytuacja nastąpiła z powodu źle zaprojektowanych klas i już teraz widać tego efekty uboczne – nasz kod nie jest testowalny, w takim stopniu jakim byśmy chcieli.

No bo jak dostać się do pól prywatnych zainicjalizowanych w klasie?

class UserService {
    public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");
    private final UserDao userDao = new UserDao();
    private final UserValidator userValidator = new UserValidator();
    private final CountryService countryService = new CountryService();
    ...
}

Oczywiście, że w Javie da się prawie wszystko, a dostać się do prywatnych pól można bardzo prosto przy użyciu refleksji. Jednak nie o to mi chodzi – chodzi mi bardziej o konstrukcję tej klasy. Może wrócę do słów rozpoczynających ten artykuł:

[…]i w bardzo łatwy sposób podmienić ją na inną – wystarczy, że ma ten sam wspólny interfejs co klasa poprzednia.

Czy coś Ci to mówi? Czy może jest to miejsce do zastosowania dependency injection? Czy może ten prosty wzorzec projektowy pozwoli nam w 100% przetestować UserService po naszej myśli? Spróbować nie zaszkodzi.

Aby zaimplementować DI w naszym przykładzie wystarczy zrobić jeden prosty krok – zamienić to:

class UserService {
    public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");
    private final UserDao userDao = new UserDao();
    private final UserValidator userValidator = new UserValidator();
    private final CountryService countryService = new CountryService();
    ...
}

Na to:

public class UserService {
    public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");
    private final UserDao userDao;
    private final UserValidator userValidator;
    private final CountryService countryService;

    UserService(UserDao userDao, UserValidator userValidator, CountryService countryService) {
        this.userDao = userDao;
        this.userValidator = userValidator;
        this.countryService = countryService;
    }
    ...
}

Tadam – dependency injection zostało zaimplementowane. Teraz w czasie tworzenia obiektu typu UserService decydujemy jak każda z zależności ma działać – to my decydujemy jak ma wyglądać baza danych, jak ma wyglądać walidacja użytkownika lub skąd mają być pobierane konkretne kraje!

Co prawda aktualny kod jeszcze nie umożliwia nam łatwiejszego testowania – brakuje nam teraz tylko pewnej abstrakcji, na której moglibyśmy oprzeć swoją implementację w fazie testów. Stwórzmy interfejs odpowiadający za komunikację z webserwisem:

interface CountryRestService {
    Country getCountryByName(String name);
}

I zaimplementujmy go w CountryService:

class CountryService implements CountryRestService {

Zaś w UserService musimy zmienić jeszcze tylko typ z CountryService na CountryRestService, aby móc wprowadzać własną implementację podczas testów:

public class UserService {
    public final static Country DEFAULT_COUNTRY = new Country("LONDON", 668000004L, "Wielka Brytania");
    private final UserDao userDao;
    private final UserValidator userValidator;
    private final CountryRestService countryService;

    UserService(UserDao userDao, UserValidator userValidator, CountryRestService countryService) {
    ...
}

Mając tak napisany kod wracamy do testów i poprawiamy tam inicjalizację obiektu UserService.

Własna implementacja

Inicjalizacja obiektu UserService w testach aktualnie wygląda tak:

UserService userService = new UserService();

Niestety domyślny konstruktor zniknął, my zaimplementowaliśmy DI (UFF, ile było przy tym roboty!), więc musimy sami teraz podać z jakich zależności ma korzystać klasa UserService.

Przemyślmy to:

  • UserDao – nie musimy zmieniać jej implementacji, baza danych jest uruchamiana w pamięci RAM (HashMap), nie stoi na zewnętrznym serwerze, więc nie ma problemu;
  • UserValidator – w tym przypadku też nie ma problemu, ponieważ klasa nie korzysta z żadnych zewnętrznych serwisów itd.;
  • CountryRestService – tutaj jest nasz rodzynek, który musimy zaimplementować po swojemu, a jak to zrobić? Bardzo prosto, przy użyciu klasy anonimowej;

Czyli inicjalizacja będzie wyglądała teraz tak:

// given
UserService userService = new UserService(new UserDao(), new UserValidator(), new CountryRestService() {
    @Override
    public Country getCountryByName(String name) {
        return POLAND_COUNTRY;
    }
});

Implementacja interfejsu CountryRestService jest podawana bezpośrednio podczas tworzenia obiektu UserService – zauważ, że jest ona bardzo prymitywna. Zamiast odpytywać webserwis i czekać na jego odpowiedź natychmiast zwracam oczekiwaną przeze mnie wartość. Tadam – webserwis nie jest dłużej nam potrzebny w fazie testów!

Oczywiście implementacja może być różna zależnie od tego jaką symulację chcę przetestować – wróćmy do przypadku, gdy potrzebowaliśmy przetestować sytuację, gdy zewnętrzny serwis po prostu zdechnie.

@Test
public void shouldAddDefaultCountryToUserWhenRestApiDoesntReply() {
    // given
    UserService userService = new UserService(new UserDao(), new UserValidator(), new CountryRestService() {
        @Override
        public Country getCountryByName(String name) {
            throw new RuntimeException();
        }
    });

    UserDTO userDTO = new UserDTO("pablo", "pablo123", POLAND);
    User expectedUser = new User("pablo", "pablo123", UserService.DEFAULT_COUNTRY);
    expectedUser.setId(0L);

    // when
    User resultUser = userService.createUser(userDTO);

    // then
    Assert.assertEquals(expectedUser, resultUser);
}

Wystarczy, że w implementacji rzucimy wyjątkiem, a sam test przejdzie na zielono – w końcu udało nam się „uśmiercić” webserwis.

Dodatkowe uwagi

  • W przypadku, gdy w wielu miejscach korzystasz z tej samej implementacji możesz wyciągnąć ją do wspólnej zmiennej w klasie i używać jej w wielu miejscach – pozwoli to zaoszczędzić wiele czasu.
public class UserServiceTest {
    private final String POLAND = "poland";
    private final Country POLAND_COUNTRY = new Country("Warsaw", 38437239L, "Polska");
    private final CountryRestService restServiceWhichReturnsPolandEverytime = new CountryRestService() {
        @Override
        public Country getCountryByName(String name) {
            return POLAND_COUNTRY;
        }
    };
    ...
}
  • Korzystajmy z udogodnień Javy 8 – czyli wyrażeń Lambda, które pozwalają nieco skrócić powyższą klasę anonimową:
private final CountryRestService restServiceWhichReturnsPolandEverytime = name -> POLAND_COUNTRY;

Konkluzja

Na sam koniec tego artykułu chcę podsumować to wszystko co się stało powyżej i jest już dawno za Toba – chcę, abyś zrozumiał czemu jest stosowany tak prosty zabieg jak dependency injection.

Pierwszy problem już zapewne zrozumiałeś – w przypadku, gdy bazujemy na kodzie produkcyjnej aplikacji, nie jesteśmy w stanie zasymulować takiej sytuacji, jaką byśmy chcieli. Wiąże się to – tak jak w naszym przypadku – z brakiem możliwości przetestowania napisanego kodu na każdy możliwy sposób. Jest to dla mnie największy problem, ale idźmy dalej.

Kolejny problem jest natury biznesowej – zastanawiałeś się kiedykolwiek ile razy dziennie programista musi uruchamiać takie testy na swoim lokalnym komputerze? Co w przypadku, gdy mamy np. 50 testów wykorzystujących zewnętrzne webserwisy i za każdym razem odpytują je o potrzebne informacje?

Odpowiedź jest prosta – biznes traci. Tracimy pieniądze płacąc np. za zewnętrzne API (należących do firm trzecich) lub na utrzymanie własnej infrastruktury, która jest potrzebna do testów – ABSURD!

Sytuacja opisana w tym artykule niesie ze sobą kolejny problem – co w przypadku, gdy nasza firma jest w stanie utrzymywać infrastrukturę (po prostu firmę na nią stać), ale np. serwery nie działają przez 24h z powodu maintenance? Czy przez okres 24h firma ma zrezygnować z wdrożeń nowych wersji aplikacji na produkcję, z powodu nie przejścia testów bo wykorzystane serwery do testów zdechły? Co z programistą, który chce testować swój kod lokalnie?

Bedąc już na finiszu możemy w końcu odpowiedź na pytanie:

Czy dependency injection ułatwia testowanie?

Moja odpowiedź brzmi tak – choć w praktyce, aby osiągnąć podobny rezultat korzysta się z gotowych bibliotek, o których już w następnym wpisie.

 

A czy Ty stosujesz dependency injection w swoim kodzie?

 

Kod z artykułu znajdziesz w tym repozytorium.