
Seria o testach jednostkowych
- Wstęp do testów jednostkowych
- Test Driven Development w praktyce
- Czy dependency injection ułatwia testowanie?
- Mocki w testach jednostkowych
- 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 wpisie. Must 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.