Testy jednostkowe, TDD, Mocki

Testy jednostkowe służą do sprawdzania fragmentów kodu np. metod czy całych klas oraz w dalszej kolejności interakcji pomiędzy klasami.

Testy jednostkowe w języku Java

Wśród najbardziej znanych frameworków do testów jednostkowych w Javie jest JUnit. W dalszej części artykułu poruszę najważniejsze metody testowe udostępnione przez tą oraz inne biblioteki.

Asercje

Podstawowymi metodami udostępnionymi przez JUnit są asercje, które określają czy test zakończył się poprawnie czy nie, np.:

  • assertTrue(sprawdzające czy argument ma wartość true),
  • assertTrue(password.isValid())
  • assertNull(sprawdzające czy argument jest nullem)

Matchery

By testy były bardziej czytelne można użyć dodatkowych bibliotek z matcherami tj.: AssertJ i Hamcrest. Dzięki nim napiszemy bardziej czytelne asercje gdyż dostarczają one metodę assertThat, która nie występuje JUnit.

Składnia matchera z biblioteki Hamcrest:

assertThat(password.isValid(), is(true))
assertThat(givenNumber, equalTo(2))

Przez użycie matchera anyOf(), allOf() asercje można łączyć:

assertThat(car.getName(), anyOf(
		notNullValue(),
		equalTo("BMW")
));

Testowanie kolekcji

Testować można również kolekcje (przykładowy kod w zapisie hamcrestowym):

  • czy jest pusta
assertThat(testedArray, empty());
  • czy ma określony rozmiar
assertThat(testedArray, hasSize(0));
  • czy zawiera dany element (cars to lista)
assertThat(cars, hasItem(car));
  • czy kolekcje zawierają takie same elementy w tej samej kolejności
assertThat(cars1, is(cars2));

Pisanie metod testujących

Przykładowa metoda testująca rozmiar kolekcji może wyglądać w taki sposób:

@Test
void addingCarToArrayAndChekcSize() {
	//given
	Car car = new Car("BMW");
	//when
	cars.add(car);
	//then
	aassertThat(cars, hasSize(1));
}

Warto stosować jakąś strukturę w naszych testach, w powyższym przykładzie mamy podział na 3:

  • given- założenia początkowe, tworzymy obiekty
  • when- metody, operacje które chcemy przetestować
  • then- weryfikacja przebiegu testu.

Testy parametryzowane

Testy parametryzowane to metody, które mają parametry (argumenty). Do testów parametryzowanych potrzebna jest biblioteka junit-params. W takich testach musimy też wpisać źródło wartości argumentów np. poprzedzając test adnotacją @ValueSOurce().

@ParameterizedTest
@ValueSource(ints = {5,10,15,20})
void numberShouldBeLowerThan30(int number){
	assertThat(number, lessThan(20));
}

Jako parametr możemy dawać również strukturę enum.
Jako źródło danych może też posłużyć metoda:

@ParameterizedTest
@MethodSource("createWomenProfiles")
void nameEndsWithLetterAAndAgeGraterThan20(String name, int age){
	assertThat(name, endsWith("a");
	assertThat(age, greaterThan(20);
}

private static Stream<Arguments> createWomenProfiles(){
	return Stream.of(
		Arguments.of("Anna", 21),
		Arguments.of("Gosia", 22)
	);
}

Metoda dostarczająca argumenty może też wyglądać w taki sposób:

@ParameterizedTest
@MethodSource("createWomenProfiles")
void namesShouldEndWithAletter(String name) {
        assertThat(name, endsWith("a"));
}

private static Stream<String> createWomenProfiles() {
     List<String> cakeNames = Arrays.asList("Anna", "Gosia");
     return cakeNames.stream();
}

Dane można przekazywać także jako zestawy, używając nagłówka @CsvSource:

@CsvSource({"Anna,22", "Gosia,21"})
void nameEndsWithLetterAAndAgeGraterThan20(String name, String age){...}

Mimo, że podajemy numer jako String (w powyższym przykładzie parametr age), można używać asercji numerycznych np. greatherThan().

Asumpcje

Pozwalają uruchomić test pod pewnymi warunkami

void newAccountWithNotNullAddressShouldBeActive() {

        //given
        Address user = new user("Anna", "female");

        //then
        assumingThat(user.gender == "female", () -> {
            assertThat(name, endsWith("a"));
        });
    }

w sekcji //then sprawdzany jest warunek czy płeć użytkownika to „female” i dopiero wtedy uruchamiany jest test.

Mock, Stub, Spy

Są to obiekty, które mają naśladować, zastępować jakąś funkcjonalność, np. obiekt jakiejś klasy. Odpowiada właściwościom obiektu, który imituje. Są one wykorzystywane w przypadku, gdy chcemy przetestować obiekt, który czerpie dane z zewnętrznego API. Dzięki obiektowi Stub, Mock lub Spy nie musimy łączyć się z API, a dostaniemy obiekt, który jest imitacją klasy i możemy z powodzeniem przygotowywać testy.

Stub

Stub ma pewne ograniczenia, może implementować jedynie metody, które są zawarte w testowanym interfejsie, więc jedna metoda interfejsu to jedna metoda testowa. Ponadto trzeba samemu utworzyć metodę, do której nie mamy dostępu i napisać ją w taki sposób by zwracała przykładowe dane.

Przykładowo, mamy interfejs, który implementuje metodę pobierającą dane z zewnętrznego API

public interface CitiesManager {
    List<Account> getAllCities();//poabiera dane z zewnętrznego API
}

Klasa, która obsługuje naszą bazę, w której jest metoda getAllPolishCities();

class CitiesService {

    private CitiesManager citiesManager;

    CitiesService(CitiesManager citiesManager) {
        this.citiesManager = citiesManager;
    }

    List<City> getAllPolishCities() {
		List<City> polishCities = new ArrayList<>()
		for(City city : citiesManager.getAllCities()){
			
			if (city.getCountry() == "Poland"){
				polishCities.add(city);
			}
		}
		return polishCities;
    }
}

Teraz trzeba stworzyć Stub który zamiast połączyć się z API i dostać listę miast, zwróci przykładowe, podane przez nas dane. Metoda testowa musi zaimplementować interfejs, który łączy się z API i przesłonić metodę.

public class CitiesManagerStub implements CitiesManager {

    @Override
    public List<Account> getAllCities() {
        City city1 = new City("Warszawa", "Poland");
        City city2 = new City("Katowice", "Poland");
        return Arrays.asList(city1, city1);
    }
}

I nasza klasa testująca CitiesService:

class CitiesServiceTest {
    @Test
    void getAllPolishCities() {
        //given
        CitiesManager citiesManagerStub = new CitiesManagerStub();
        CitiesService citiesService = new CitiesService(citiesManagerStub);

        //when
        List<City> citiesList = citiesService.getAllPolishCities();

        //then
        assertThat(citiesList, hasSize(2));
    }
}

Jak widać możemy testować tylko jeden zestaw danych, jeden scenariusz. Nie możemy sobie dopisać innej implementacji metody getAllCities np. z miastami nie z Polski i też ją przetestować, musielibyśmy usunąć te dane i wprowadzić inne lub stworzyć kolejną klasę implementującą ten interfejs, które za każdym razem gdy dodamy nową metodę do naszego interfejsu trzeba będzie pojedynczo aktualizować. Stuby więc można z powodzeniem stosować, ale głównie do małych zestawów danych, gdy nie potrzeba testować wielu scenariuszy.

Mocki

Mocki natomiast nie mają tego ograniczenia. Są bardziej elastyczne, dają więcej funkcjonalności. Możemy tworzyć wiele scenariuszy. Dostęp do nich zapewnia biblioteka Mockito.

W poniższej metodzie testowej zapewniamy zestaw danych z innej metody (poniżej) Tworzymy mock obiektu klasy CitiesManager (mock(CitiesManager.class)) a następnie precyzujemy, że metoda getAllCities() wywołana na citiesManager (w funkcji given()) ma zwrócić przygotowaną listę miast (cities).
W sekcji //when wywoływana jest metoda getAllPolishCities
No i w sekcji //then testujemy czy otrzymaliśmy listę zgodną z oczekiwaniami.

class CitiesServiceTest {

    @Test
    void getAllPolishCities() {

        //given
        List<City> cities = prepareCitiesData();
        CitiesManager citiesManager = mock(CitiesManager.class);
        CitiesService citiesService = new CitiesService(citiesManager);
        given(citiesManager.getAllCities()).willReturn(cities);

        //when
        List<Account> accountList = citiesService.getAllPolishCities();

        //then
        assertThat(accountList, hasSize(2));
	}
	void getEmptyListOfPolishCities{
	}
}

w tej klasie również w osobnej metodzie przygotowujemy dane, bez tych danych mockito zwróci wartość zgodną z oczekiwaniami tj. gdy testowana metoda zwraca wartość int, to mockito zwróci również domyślną wartość int czyli 0, gdy testowana metoda zwraca tablicę, mockito również domyślnie zwróci pustą tablicę

private List<Account> prepareCitiesData() {
    City city1 = new City("Warszawa", "Poland");
    City city2 = new City("Katowice", "Poland");

    return Arrays.asList(city1, city1);
}

Możemy dalej stworzyć następną metodę, testującą w sytuacji, gdy lista będzie pusta, tym razem przygotować listę bez polskich miast, albo w sekcji //given w metodzie willReturn wpisać .willReturn(Collections/emtyList()).

Argument matchery

Gdyby nasza metoda getAllCities() przyjmowała jakieś argumenty to w sytuacji wywoływania w funkcji given(citiesManager.getAllCities()).willReturn(cities); gdy akurat argument nie ma znaczenia , możemy użyć matcherów. Jednym z nich jest Any():

given(citiesManager.getAllCities(any())).willReturn(cities)

Zaleca się jednak by był to matcher, który imituje typ danych argumentu i w takiej sytuacji można sprecyzować metodę any():

given(citiesManager.getAllCities(any(Country.class))).willReturn(cities)

Dzięki mockom możemy testować klasy abstrakcyjne, ale jednym z ograniczeń mocków jest to, że nie wykonują się metody zwracające void.

Spy

Mocki są to imitacje obiektów, można na nich wykonywać prawdziwe metody z naśladowanej klasy jednak nie jest to zalecane.

W takim celu można posłużyć się obiektem Spy, który częściowo zachowuje się jak prawdziwy obiekt, a częściowo jak mock. Dzięki nim możemy niektóre metody mockować a inne wywoływać realne, takie jak w rzeczywistej klasie.
Obiekt taki tworzy się bardzo prosto, korzysta on jednak z konstruktora bezargumentowego (jeżeli nie ma go w naszej klasie należy taki stworzyć):

City city = spy(City.class);

TDD- Test Driven Development

Cała idea TDD opiera się na tym, że najpierw piszemy test danej funkcjonalności a następnie kod, który tą funkcjonalność wprowadza.

Cykl TDD można opisać w 3 krokach:

  1. pisanie testu, który nie przechodzi
  2. pisanie minimalnej ilości kodu, która pozwala by test był pozytywny
  3. refaktoryzacja napisanego kodu, zarówno funkcjonalnego jak i testów.

Najłatwiej będzie posłużyć się prostym przykładem

  1. Chcemy stworzyć aplikację książka adresowa.
  2. Piszemy pierwszy test, sprawdzający możliwość dodania adresata do kontaktów.
void shouldBeAbleToAddContactToAdresssBook() {
	//given
	Contact contact = new Contact();
}
  1. Test nie przechodzi, musimy utworzyć klasę Contact i stworzyć konstruktor.
  2. Test przechodzi.
  3. Następnie chcemy żeby adresat miał imię nazwisko oraz nr telefonu, więc wracamy do naszego testu i wprowadzamy dane:
void shouldBeAbleToAddContactToAdresssBook() {
	//given
	Contact contact = new Contact("Adam", "Kowalski", 555444555 );
}
  1. Test nie przechodzi, wracamy do naszej klasy Contact i tworzymy pola oraz konstruktor.
  2. Test przechodzi, następnie tworzymy książką adresową
void shouldBeAbleToAddContactToAdresssBook() {
	//given
	Contact contact = new Contact("Adam", "Kowalski", 555444555 );
	AdressBook adressBook = new AdressBook();
}

W następnych krokach Ponownie tworzymy klasę książki adresowej, testujemy, chcemy dodać do listy adresów, więc piszemy test, ale nie przechodzi tworzymy metodę add, na razie pustą. W sekcji //when dodajemy adresata jako argument metody np. adressBook.add(contact). Zmieniamy metodę by przyjmowała argument, test przechodzi, następnie piszemy asercję, że w książce znajduje się jeden rekord.

assertThat(addressBook.getAllContacts(), hasSize(1));

Tworzymy więc w klasie Lisę z adresami, w metodzie add() dodajemy linijkę dodania kontaktu do listy, oraz tworzymy metodę getAllContacts(), gdzie listę zwracamy

Jak widać, TDD przysparza sporo dodatkowych czynności i pracy, jednak ma sporo zalet np:

  • kod na bieżąco jest porządkowany i refaktoryzowany,
  • każda funkcjonalność jest przetestowana,
  • minimalizuje się niepotrzebny kod.

Dodaj komentarz