Mockito

Czym jest Mockito

Mockito to biblioteka, która jest aktualnie niezbędnym narzędziem do tworzenia testów jednostkowych. Pozwala na pozbycie się niechcianej zależności, żeby łatwiej móc testować w izolacji.

Przykład do przetestowania

Zacznijmy od przykładu. Mamy do przetestowania fragment aplikacji, który losuje nick oraz avatar użytkownika, jeśli nie zostały one ustawione.

User to zwykłe POJO:

public class User {
    public String username;
    public String email;
    public String avatar;
    // getter + settery + toString()
}

Wywołanie metody:

public class App {
    public static void main(String[] args) {
        UserBuilder userBuilder = new UserBuilder();
        User user = userBuilder.createUser("email@example.com", null, null);
        System.out.println(user);
    }
}

No i właściwa metoda, którą chcemy przetestować:

public class UserBuilder {

    private static final String RANDOM_USER_URL = "https://random-data-api.com/api/users/random_user";

    public User createUser(String email, String username, String avatar) {
        User user = new User();
        user.setEmail(email);

        RandomUserDto randomUserDto = null;
        if (username == null || avatar == null) {
            randomUserDto = fetchRandomUser();
        }
        user.setAvatar(avatar != null ? avatar : randomUserDto.getAvatar());
        user.setUsername(username != null ? username : randomUserDto.getUsername());

        return user;
    }

    private RandomUserDto fetchRandomUser() {
        try {
            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(RANDOM_USER_URL))
                    .build();
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            Gson gson = new Gson();
            String body = response.body();
            return gson.fromJson(body, RandomUserDto.class);
        } catch (Exception e) {
            throw new RuntimeException("Coś poszło nie tak", e);
        }
    }
}

W skrócie:

  1. Sprawdzamy, czy została podana nazwa użytkownika i avatar
  2. Jeśli chociaż jednego brakuje, to odpytujemy API z prośbą o wylosowanie danych użytkownika.
  3. To, co otrzymaliśmy, ustawiamy jako dane użytkownika (tylko w przypadku jeśli użytkownik nie ustawił tego sam)

Co przydałoby się przetestować?

  • czy pola klasy User są ustawiane na takie jak przekazane do metody
  • czy pola klasy User są losowane, jeśli nie zostały przekazane do metody
  • fantastycznie byłoby móc upewnić się, że API nie zostało odpytane jeśli nie było takiej potrzeby (do metody trafiło zarówno avatar, jak i username)

Za pomocą samej biblioteki JUnit może być tutaj ciężko, o ile to w ogóle możliwe. Zastanówmy się jeszcze jakie problemy możemy napotkać:

  • korzystamy tutaj z tzw. zewnętrznej zależności, w tym przypadku to API, na którego działanie nie mamy wpływu
  • API może mieć limity, albo pobierać opłaty za korzystanie
  • mogą wystąpić problemy z połączeniem internetowym
  • taki test może wykonywać się długo (>100 ms) zamiast <1ms

No i tutaj pojawia się Mockito, które pozwala nam w wygodny sposób zastąpić zewnętrzną zależność. Innymi słowy, chcemy zastąpić fetchRandomUser() własną implementacją na czas testów. Ta implementacja będzie zwracała zawsze te same dane.

Zacznijmy od wyciągnięcia tej metody do osobnej klasy. Tak robi się najczęściej i jest wygodniej. Utworzymy więc klasę RandomUserService i tam przenieśmy metodę.

public class RandomUserService {

    private static final String RANDOM_USER_URL = "https://random-data-api.com/api/users/random_user";

    public RandomUserDto fetchRandomUser() {
        try {
            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(RANDOM_USER_URL))
                    .build();
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            Gson gson = new Gson();
            String body = response.body();
            return gson.fromJson(body, RandomUserDto.class);
        } catch (Exception e) {
            throw new RuntimeException("Coś poszło nie tak", e);
        }
    }
}

Klasa UserBuilder wygląda teraz tak:

public class UserBuilder {

    public User createUser(String email, String username, String avatar) {
        User user = new User();
        user.setEmail(email);

        RandomUserDto randomUserDto = null;
        if (username == null || avatar == null) {
            RandomUserService randomUserService = new RandomUserService();
            randomUserDto = randomUserService.fetchRandomUser();
        }
        user.setAvatar(avatar != null ? avatar : randomUserDto.getAvatar());
        user.setUsername(username != null ? username : randomUserDto.getUsername());

        return user;
    }
}

Jest tutaj jeszcze jeden problem, który musimy rozwiązać przed napisaniem testu. Chodzi o tę linię:

RandomUserService randomUserService = new RandomUserService();

Aktualnie UserBuilder sam tworzy RandomUserService. Może też powiedzieć inaczej: UserBuilder aktualnie decyduje jakiej implementacji RandomUserService użyć. Jeśli dodać inny sposób pobierania losowego użytkownika to wymagana będzie zmiana w UserService, co bezpośrednio uderza w Single Responsibility Principle z zasad SOLID. Możemy to w bardzo prosty sposób poprawić, dodając tę zależność do konstruktora:

public class UserBuilder {

    private RandomUserService randomUserService;

    public UserBuilder(RandomUserService randomUserService) {
        this.randomUserService = randomUserService;
    }

    public User createUser(String email, String username, String avatar) {
        User user = new User();
        user.setEmail(email);

        RandomUserDto randomUserDto = null;
        if (username == null || avatar == null) {
            randomUserDto = randomUserService.fetchRandomUser();
        }
        user.setAvatar(avatar != null ? avatar : randomUserDto.getAvatar());
        user.setUsername(username != null ? username : randomUserDto.getUsername());

        return user;
    }
}

Teraz UserBuilder nie decyduje o tym czy skorzysta bezpośrednio z RandomUserService, czy z jakieś nadpisanej klasy. Na diagramie można to przedstawić tak:

Będziemy mogli sobie bez problemu podmienić RandomUserService na dowolną jego implementację, co jest główną ideą Mockito.

Testujemy z użyciem Mockito

W końcu możemy przejść do pisania testów :)

Dodajmy wymagane następujące zależności:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.23.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>4.8.0</version>
    <scope>test</scope>
</dependency>

Oprócz Mockito dodaję JUnit, oraz AssertJ, czyli bibliotekę do tzw. płynnych asercji. Napiszmy pierwszy test, na razie bardzo prosty, bez Mockito:

@Test
public void shouldCreateUserWithGivenData() {
    // given
    UserBuilder userBuilder = new UserBuilder(null);
    String email = "user@example.com";
    String username = "magiczny_krzysztof";
    String avatar = "https://www.funny.pl/images/items/d7a84628c025d30f7b2c52c958767e76.jpg";

    // when
    User user = userBuilder.createUser(email, username, avatar);

    // then
    assertThat(user.getEmail()).isEqualTo(email);
    assertThat(user.getUsername()).isEqualTo(username);
    assertThat(user.getAvatar()).isEqualTo(avatar);
}

Sprawdzamy, czy wszystkie pola ustawiają się poprawnie jeśli nie ma potrzeby losowania danych. Teraz pora na losowanie danych.

@Test
public void shouldUseRandomUserData() {
    // given
    RandomUserService randomUserService = Mockito.mock(RandomUserService.class);
    RandomUserDto randomUserDto = new RandomUserDto();
    randomUserDto.setUsername("random_username_123");
    randomUserDto.setAvatar("http://example.com/image.jpg");
    Mockito.when(randomUserService.fetchRandomUser()).thenReturn(randomUserDto);

    UserBuilder userBuilder = new UserBuilder(randomUserService);
    String email = "user@example.com";

    // when
    User user = userBuilder.createUser(email, null, null);

    // then
    assertThat(user.getEmail()).isEqualTo(email);
    assertThat(user.getUsername()).isEqualTo("random_username_123");
    assertThat(user.getAvatar()).isEqualTo("http://example.com/image.jpg");
}

Tutaj zatrzymamy się na chwilę, bo pojawia się pierwsze użycie Mockito.

RandomUserService randomUserService = Mockito.mock(RandomUserService.class);

Metoda Mockito.mock(Class<T> classToMock) zwraca tzw. mock klasy. Mock to stworzona w locie implementacja klasy, której m.in. możemy nadać zachowanie. Z punktu widzenia reszty kodu jest to po prostu zwykły obiekt i będzie traktowany jako "zwykła" klasa RandomUserService. Domyślnie mock nadpisuje wszystkie metody klasy i zawsze zwraca null. Możemy w wygodny sposób nadpisać wybrane metody:

RandomUserDto randomUserDto = new RandomUserDto();
randomUserDto.setUsername("random_username_123");
randomUserDto.setAvatar("http://example.com/image.jpg");

Mockito.when(randomUserService.fetchRandomUser()).thenReturn(randomUserDto);

Tutaj najciekawsza jest ostatnia linia. Poniekąd rozkazujemy temu mockowi, żeby w przypadku gdy zostanie odpytany o metodę fetchRandomUser() to ma wtedy podać nasz wcześniej przygotowany obiekt randomUserDto.

Dzięki temu zachowanie tego serwisu stało się przewidywalne i resztę testu możemy już wykonać bez większych problemów. Ważne jednak, żeby do UserBuilder przekazać nasz mock, a nie "prawdziwy" serwis;

UserBuilder userBuilder = new UserBuilder(randomUserService); // przekazujemy mock
UserBuilder userBuilder = new UserBuilder(new RandomUserService()); // NIE przekazujemy "prawdziwej" klasy!

Sprawdzanie interakcji z mockiem

Oprócz nadawania mockowi zachowania możemy również sprawdzić, czy została na nim wykonana jakaś metoda. W tym przypadku chcemy, aby odpytanie o losowe dane użytkownika odbyło się tylko w przypadku, gdy brakuje nazwy użytkownika lub avataru. W przeciwnym przypadku nie odpytujemy serwisu, bo te dane nie są nam zwyczajnie potrzebne.

if (username == null || avatar == null) {
    randomUserDto = randomUserService.fetchRandomUser();
}

Pozbądźmy się na chwilę tego ifa:

public User createUser(String email, String username, String avatar) {
    User user = new User();
    user.setEmail(email);
    RandomUserDto randomUserDto = randomUserService.fetchRandomUser();
    user.setAvatar(avatar != null ? avatar : randomUserDto.getAvatar());
    user.setUsername(username != null ? username : randomUserDto.getUsername());
    return user;
}

Teraz odpytanie wykona się za każdym razem. Napiszmy test, który upewni nas, że metoda fetchRandomUser() nie została uruchomiona:

@Test
public void shouldNotCallApiIfNotNeeded() {
    // given
    String email = "user@example.com";
    String username = "magiczny_krzysztof";
    String avatar = "https://www.funny.pl/images/items/d7a84628c025d30f7b2c52c958767e76.jpg";
    RandomUserService randomUserService = Mockito.mock(RandomUserService.class);
    UserBuilder userBuilder = new UserBuilder(randomUserService);

    // when
    User user = userBuilder.createUser(email, username, avatar);

    // then
    Mockito.verify(randomUserService, Mockito.never()).fetchRandomUser();
}

Spójrz na ostatnią linię. Sprawdzamy w ten sposób, czy na danym mocku NIE została uruchomiona metoda fetchRandomUser().

Test zakończy się niepowodzeniem z informacją:

org.mockito.exceptions.verification.NeverWantedButInvoked: 
randomUserService.fetchRandomUser();
Never wanted here:
-> at pl.javastart.example.UserBuilderTest.shouldNotCallApiIfNotNeeded(UserBuilderTest.java:62)
But invoked here:
-> at pl.javastart.example.UserBuilder.createUser(UserBuilder.java:14) with arguments: []

Inna opcja sprawdzenia braku interakcji na dowolnej z metod to:

Mockito.verifyNoInteractions(randomUserService);

Można też sprawdzać, czy było np. dokładnie 1 wywołanie metody, albo czy metoda została wywołana z odpowiednim argumentem itd. Jest to jednak poza zakresem tego wpisu.

Podsumowanie

Mockito to podstawowe narzędzie każdego programisty Java. Pozwala na szybkie i wygodne pozbycie się niechcianych zależności, a także weryfikację czy testowany kod poprawnie się zachowuje.

Kod źródłowy tego wpisu znajdziesz tutaj: https://github.com/javastartpl/examples/tree/master/junits/junits-mockito

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.