Гайд по использованию JUnit 5, Mockito и AssertJ для проверки поведения кода

от автора

Меня зовут Игорь Симаков, я тимлид Java-разработки в команде Маркетплейс Банки.Ру. Сегодня на практическом примере разберу использование UNIT-тестирования. Оно применяется как для тестирования состояния, так и для проверки поведения кода. В этом материале сосредоточусь на последнем аспекте. Покажу, как использовать JUnit, Mockito и AssertJ для тестирования кода, а также JaCoCo для оценки покрытия тестами на примере простого мини-сервиса.

Эта статья основана на моем внутреннем воркшопе, который я проводил для своих коллег. В моем репозитории можно ознакомиться с текстом доклада. Там содержится обзор основных понятий, используемых в статье, а также подробное описание инструментов.

Сервис для примера

Для наглядной демонстрации инструментов  UNIT-тестирования я разработал сервис, который получает прогноз погоды по городам.
Бизнес-логика несложная: сервис выбирает из рандома 50, 100 и 150 городов, отправляет запросы, получает данные в формате englishname и ключ, и затем получает погодные условия по этому ключу. Код сервиса доступен на GitHub.

@RequiredArgsConstructor public class AccuweatherService {    private final AccuweatherClient accuweatherClient;    private final EventService eventService;     public void checkAccuweather() {        Stream.of(50, 100, 150)            .findAny()            .map(TopCitiesCount::findByValue)            .map(this::getTopCityLocation)            .map(this::getCurrentConditionByLocation)            .ifPresent(eventService::sendEvent);    }     public CurrentCondition getCurrentConditionByLocation(      final LocationRoot locationRoot) {        return Arrays.stream(          accuweatherClient.getCurrentConditionsByLocationKey(            locationRoot.getKey()))            .findFirst()            .orElseThrow();    }     public LocationRoot getTopCityLocation(final TopCitiesCount citiesCount) {        return Arrays.stream(accuweatherClient.getTopcities(citiesCount))            .findAny()            .orElseThrow();    }     public void callWithException() {        throw new ServiceException("Smthing go wrong!", null);    } }

Итак, у нас есть рабочий код, и теперь я хочу покрыть его тестами. Как это сделать? Для этого я буду использовать четыре основных инструмента:

Основные инструменты тестирования

  • JUnit. фреймворк для тестирования Java-приложений. Предоставляет аннотации и классы для определения и запуска тестов, а также для проверки ожидаемых результатов. JUnit значительно облегчает создание и выполнение тестовых сценариев.

  • Mockito. Библиотека для создания моков (фиктивных объектов) в тестах. Позволяет настроить поведение моков и проверить, как взаимодействует тестируемый код с этими моками.

  • AssertJ. Библиотека для создания утверждений в тестах. Предоставляет более выразительные методы для проверки ожидаемых результатов, что делает тесты более читаемыми и понятными.

  • JaCoCo (Java Code Coverage). Инструмент, который измеряет покрытие кода тестами. Анализирует выполнение тестов и предоставляет отчеты о покрытии, позволяя определить, какие части кода были протестированы, а какие — нет. JaCoCo помогает локализовать недостаточно протестированные участки кода и повышает качество тестирования.

В примерах тестов я буду комбинировать все эти инструменты.

Анализируем покрытие кода тестами JaCoCo

Предположим, мы приняли гипотетический сервис на поддержку и при этом практически не знакомы с кодовой базой. Нужно определить, какие куски кода стоит покрыть тестами. Для этого я использую JaCoCo. В JaCoCo несколько видов покрытия. Можно оценить, какой процент строк кода и ветвей был покрыт тестами, покрытие инструкций, методов и классов. Каждый вид покрытия можно настроить, указать минимальный порог, который будет использован при сборке.

Итак, я подключаю и настраиваю плагин JaCoCo: могу исключить из проверки пакеты, которые не хочу покрывать тестами. Например, покрытие модели данных. Также указываю минимальный порог тестирования, ниже которого сборка не пройдет.

<plugin>    <groupId>org.jacoco</groupId>    <artifactId>jacoco-maven-plugin</artifactId>    <configuration>        <excludes>            <exclude>ru/example/com/model/**/*</exclude>            <exclude>ru/example/com/client/*</exclude>            <exclude>ru/example/com/utils/*</exclude>        </excludes>    </configuration>    <executions>        <execution>            <id>prepare-agent</id>            <goals>                <goal>prepare-agent</goal>            </goals>        </execution>        <execution>            <id>report</id>            <phase>test</phase>            <goals>                <goal>report</goal>            </goals>        </execution>        <execution>            <id>check-minimal</id>            <phase>package</phase>            <goals>                <goal>check</goal>            </goals>            <configuration>                <rules>                    <rule>                        <element>BUNDLE</element>                        <limits>                            <limit>                                <counter>INSTRUCTION</counter>                                <value>COVEREDRATIO</value>                                <minimum>1.0</minimum> <!-- вот тут -->                            </limit>                            <limit>                                <counter>BRANCH</counter>                                <value>COVEREDRATIO</value>                                <minimum>1.0</minimum> <!-- и тут -->                            </limit>                            <limit>                                <counter>CLASS</counter>                                <value>MISSEDCOUNT</value>                                <maximum>0</maximum> <!-- и тут -->                            </limit>                            <limit>                                <counter>METHOD</counter>                                <value>MISSEDCOUNT</value>                                <maximum>0</maximum> <!-- и тут -->                            </limit>                            <limit>                                <counter>LINE</counter>                                <value>MISSEDCOUNT</value>                                <maximum>0</maximum> <!-- и тут -->                            </limit>                        </limits>                    </rule>                </rules>            </configuration>        </execution>    </executions> </plugin> 

Запускаю сборку своего сервиса, прогоняю тесты и вижу, что сборка падает: три класса не покрыты по строкам и по методам.

В результате проверки JaCoCo отдает HTMLку (можно найти в target/site/jacoco/index.html). Захожу на сервисный слой, где есть разбивка по методам, и вижу, что ни один из них не покрыт. Значит, нужно заняться тестированием, чтобы достичь минимального порога.

Результат проверки JaCoCo

Результат проверки JaCoCo
Проваливаемся в код и посмотрим детальное покрытие

Проваливаемся в код и посмотрим детальное покрытие

Приступаем к тестированию 

Создаем моки и спай с помощью Mockito 

В Mockito есть несколько типов объектов для тестирования:

  • Mock — это фиктивный объект, который можно настроить так, чтобы он возвращал определенные значения из вызовов методов. Например, у нас есть класс с методами A, B и C. Мы хотим протестировать метод A, при этом вызовы методов B и C не должны выполняться. Чтобы «заглушить» вызовы B и C и вернуть заданный результат, используем mock. 

  • Spy — это полностью функционирующий объект. Если нам нужно проверить, вызывался ли метод B из метода A, используем spy. Spy позволяет отслеживать вызовы всех методов объекта и проверять их.

В моем примере я полностью мокирую клиент AccuweatherClient, а для eventService использую аннотацию @Spy . Мне нужно убедиться, что он был вызван с ожидаемыми параметрами. Также я использую аннотацию @InejctMocks для внедрения зависимостей в сервис и @ExtendWith, чтобы все заработало.

@ExtendWith(MockitoExtension.class) class AccuweatherServiceTest {     @Mock    private AccuweatherClient accuweatherClient;    @Spy    private EventService eventService;    @InjectMocks    private AccuweatherService accuweatherService;

Тестируем getСurrentСonditionByLocation с помощью Mockito.when и Mockito.thenReturn

Метод getCurrentConditionByLocation принимает объект LocationRoot, вызывает метод accuweatherClient.getCurrentConditionByLocationKey() и возвращает первый попавшийся элемент. Если ничего не найдено — выбрасывает исключение.

public CurrentCondition getCurrentConditionByLocation(   final LocationRoot locationRoot) {    return Arrays.stream(accuweatherClient.getCurrentConditionsByLocationKey(      locationRoot.getKey()))        .findFirst()        .orElseThrow(); }

Я уже замокал accuweatherClient и теперь хочу определить поведение его методов в тестировании с помощью when() и thenReturn() из библиотеки Mockito.

Структура теста

GIVEN — подготовка тестовых данных.

Когда будет вызываться метод getCurrentConditionByLocation с заданным параметром (в нашем случае это request.getKey), мы будем возвращать currentConditions.

currentConditions — это массив объектов, который я создаю с помощью Mockito.mock(). Мокирую его, потому что его состояние не важно.

WHEN. Вызываем тестируемый метод. 

THEN. Используя assertThat() и isEqualTo из библиотеки AssertJ, я проверяю, что полученный результат метода соответствует элементу, который я положил в массив. С помощью Mockito.verify() проверяю, что getCurrentConditionByLockation был вызван ровно один раз с заданным параметром getKey.

@Test void getCurrentConditionByLocationShouldWork() {    //GIVEN    var currentCondition = Mockito.mock(CurrentCondition.class);    CurrentCondition[] currentConditions = {currentCondition};     var request = DataProvider.prepareLocationRoot().build();    Mockito.when(accuweatherClient.getCurrentConditionsByLocationKey(request.getKey()))        .thenReturn(currentConditions);     //WHEN    var result = accuweatherService.getCurrentConditionByLocation(request);     //THEN    assertThat(result).isEqualTo(currentCondition);    Mockito.verify(accuweatherClient, Mockito.times(1))        .getCurrentConditionsByLocationKey(request.getKey()); }

Отдельно про Mockito.verify

В моем примере я замокал метод вызова клиента accuweatherClient, поэтому после вызова проверяющего метода мне нужно убедиться, что accuweatherClient действительно был вызван. Для этого я применил Mockito.verify().

Я также использовал параметр times(), где указал, что метод getCurrentConditionByLocationKey с параметром request.getKey должен быть вызван один раз. Вообще, единицу можно не писать: при использовании times() по умолчанию проверяется именно единичный вызов тестируемого метода.

Можно использовать и другие количественные проверки:

  • atLeastOnce(),

  • atMostOnes(), 

  • atLeast(),

  • atMost(), 

  • never().

Как проверить, что вызван только один метод и никакие другие 

Еще одна хорошая практика — проверять, чтобы никакие другие методы для этого клиента не были вызваны. Допустим, у нас в методе getCurrentConditionByLocation есть вызов какого-то другого метода. Если прогоним тест и метод выполнится, это будет означать, что кое-что в тестировании мы упустили.

Чтобы такого не происходило, нужно либо провести проверку этого дополнительного метода с использованием Mockito.verify() и убедиться, что он тоже был вызван. Либо применить never(), если хотим удостовериться, что никакого другого метода не было вызвано.

Для этих же целей у Mockito есть verifyNoMoreInteractions(). Используя аннотацию @AfterEach, он будет запускаться после выполнения каждого метода и проверять, что никакие другие методы на наших замокированных зависимостях не были вызваны.

@AfterEach void afterEach() {    Mockito.verifyNoMoreInteractions(accuweatherClient, eventService); }

Теперь у меня есть полная структура теста с использованием Mockito.verify(), которая поможет убедиться в правильности вызова зависимостей.

После выполнения тестов возвращаюсь к отчету JaCoCo в формате HTML и вижу, что один метод теперь выделен зеленым цветом. Это означает, что я успешно протестировал конструктор и этот метод.

Покрытие кода повысилось, но еще недостаточно: еще есть методы, которые нужно протестировать. Поэтому тестирование продолжаю.

Покрытие увеличено до 42% по инструкциям

Результат повторной проверки JaCoCo

Тестируем метод getTopCityLocation с помощью ParameterizedTest.

Для демонстрации работы параметризованных тестов протестирую метод getTopCityLocation. Этот метод принимает на вход topCityCountenum из трех элементов: FIFTY, HUNDRED, HUNDRED_FIFTY.

public LocationRoot getTopCityLocation(final TopCitiesCount citiesCount) {    return Arrays.stream(accuweatherClient.getTopcities(citiesCount))        .findAny()        .orElseThrow(); }

Мне нужно протестировать каждый из трех элементов, но я не хочу дублировать код тестов. Для таких целей в JUnit есть аннотация @ParameterizedTest. Я также использую @EnumSource, чтобы указать enum, из которого хочу перебрать элементы. Если мне нужно исключить один или несколько элементов из тестирования, буду использовать EnumSource.Mode.EXCLUDE. В своем тесте применю его к элементу FIFTY.

@ParameterizedTest @EnumSource(    value = TopCitiesCount.class,    mode = EnumSource.Mode.EXCLUDE,    names = {"FIFTY"} ) void getTopCityLocationShouldWork(final TopCitiesCount topCitiesCount) {    //GIVEN    var locationRoot = DataProvider.buildLocationRoot().build();    LocationRoot[] locationRoots = {locationRoot};     Mockito.when(accuweatherClient.getTopcities(any(TopCitiesCount.class)))        .thenReturn(locationRoots);     //WHEN    var result = accuweatherService.getTopCityLocation(topCitiesCount);     //THEN    assertThat(result)        .usingRecursiveComparison()        .ignoringFields("englishName")        .isEqualTo(locationRoot);     Mockito.verify(accuweatherClient).getTopcities(topCitiesCount); } 

Метод getTopcities я мокирую с помощью Mockito.when() и использую новую конструкцию — argument matcher. Это механизм, который позволяет гибко определять ожидаемые аргументы при вызове мок-объектов. Argument matcher особенно полезно использовать, когда тестируемый метод ожидает специфические значения аргументов, и вы хотите, чтобы тест был более гибким и устойчивым к изменениям. Если в тесте мне не важно, какой будет параметр — главное, чтобы он был — я использую метод any().

AssertJ — usingrecursionComparison

Проверяем полученные значения с помощью метода usingRecursiveComparison() из AssertJ. Он позволяет свойство за свойством сравнивать два объекта, перебирая их и сравнивая с ожидаемым результатом. При этом можно проигнорировать определенные поля, используя команду ignoringFields. Сейчас я решил не сравнивать параметры englishName.

После прогона теста возвращаемся в JaCoCo для проверки. Видим, что второй метод теперь загорается зеленым. Покрытие увеличилось до 42% — стало лучше.

Покрытие увеличено до 42% по инструкциям

Покрытие увеличено до 42% по инструкциям

Метод getTopCityLocation стал зеленым

Тестируем callWithExceptions метод с помощью assertThatThrounBy

Для тестирования метода исключений callWithExceptions используем функционал AssertJ: метод assertThatThrounBy, который принимает лямбду. 

@Test void callWithExceptionShouldThrowServiceException() {    Assertions.assertThatThrownBy(() -> accuweatherService.callWithException())        .isInstanceOf(ServiceException.class)        .hasMessageContaining("Smthing go wrong!")        .hasStackTraceContaining(      "ru.simakov.com.service.AccuweatherService. callWithException"); }

Что проверяем в исключениях: 

  • класс исключения является инстансом класса ServiceExceptionisInstanceOf(ServiceException.class)

  • сообщение, которое появляется при ошибке — hasMessageContaining("Smthing go wrong!")

  • стек трейс контейнер содержит наш сервис и наш метод — hasStackTraceContaining("ru.simakov.com.service.AccuweatherService.callWithException")

После запуска возвращаемся в JaCoCO и вновь проверяем покрытие. Поднялось!

Метод callWithException стал зеленым

Метод callWithException стал зеленым

Тестируем основной метод chechAccueweather

Этот метод рандомно выбирает 50, 100 или 150 значений, затем получает из них enum с помощью findByValue. После чего вызывает метод getTopCityLocation, затем getCurrentConditionByLocation и отправляет результат в очередь.

public void checkAccuweather() {    Stream.of(50, 100, 150)        .findAny()        .map(TopCitiesCount::findByValue)        .map(this::getTopCityLocation)        .map(this::getCurrentConditionByLocation)        .ifPresent(eventService::sendEvent); }

В тесте будут использоваться два мока клиента accuweatherClient. Первый метод, getTopCities,   будет возвращать locationRoots. Второй, getCurrentConditionByLocationKey,  — возвращать массив текущих условий (currentCondition). 

После вызова checkAccuweather нужно проверить, что методы действительно были вызваны, поэтому я использую Mockito.verify()

Мне неважно, с каким enam-элементом будет вызван gettopCities, поэтому я использую any. А вот getCurrentConditionByLocation будет вызван с объектом из моего мока, из которого должно прийти 1,2,3. Поэтому я ожидаю, что getCurrentConditionByLocation будет вызван один раз именно с этим значением. Как будет выглядеть тест метода

@Test void checkAccuweatherShouldWork() {    //GIVEN    var locationRoot = DataProvider.buildLocationRoot().build();    LocationRoot[] locationRoots = {locationRoot};    var currentCondition = DataProvider.prepareCurrentConditions();    Mockito.when(accuweatherClient.getTopcities(any(TopCitiesCount.class)))        .thenReturn(locationRoots);    Mockito.when(accuweatherClient.getCurrentConditionsByLocationKey(any()))        .thenReturn(new CurrentCondition[]{currentCondition});     //WHEN    accuweatherService.checkAccuweather();     //THEN    Mockito.verify(accuweatherClient).getTopcities(any());    Mockito.verify(accuweatherClient).getCurrentConditionsByLocationKey("123");    verifySendEvent(); } 

ArgumentCaptor для проверки аргументов

В сервисе есть метод sendEvent, который получает объект currentCondition. Я хочу убедиться, что этот метод был вызван один раз, и проверить аргументы, с которыми он был вызван. Но как это сделать, если он ничего не возвращает? Для этого использую ArgumentCaptor — инструмент библиотеки Mockito, который позволяет захватывать и сохранять аргументы, переданные в методе мока.

private void verifySendEvent() {    var captor = ArgumentCaptor.forClass(CurrentCondition.class);    Mockito.verify(eventService).sendEvent(captor.capture());     assertThat(captor.getValue())        .isNotNull()        .satisfies(currentCondition1 -> assertThat(currentCondition1)            .extracting(CurrentCondition::getEpochTime,                         CurrentCondition::getWeatherText,                         CurrentCondition::isHasPrecipitation)            .containsExactly(123_456_789, "Sunny", false))        .extracting(CurrentCondition::getTemperature)        .extracting(CurrentCondition.Temperature::getImperial,                     CurrentCondition.Temperature::getMetric)        .containsExactly(            CurrentCondition.Imperial.builder()                .value(77)                .unit("Fahrenheit")                .unitType(18)                .build(),            CurrentCondition.Metric.builder()                .value(25.0)                .unit("Celsius")                .unitType(17)                .build()        ); }

Применяя его, я получу значение, которое будет содержать объект со всеми данными, переданными в метод sendEvent. Если sendEvent был вызван два раза, captor захватит два объекта, вызванных методом.

Теперь я хочу проверить, что в таком объекте getValue не будет равен нулю. Чтобы убедиться в этом, использую метод satisfies, благодаря которому на вытащенном объекте можно запустить цепочку отдельных проверок. Например, в объекте currentCondition есть вложенные объекты epochTime, weatherText, hasPrecipitation. Вместо того чтобы создавать отдельные проверки для всех трех полей, я использую команду extracting и добавляю containsExactly, чтобы внедрить сопоставления для каждого поля.

Это удобно и сокращает количество строк кода. При этом общую цепочку я не нарушил, а просто сделал ответвление.

Итак, я проверил, что в сервисе были вызваны три метода, и что sendEvent был вызван с теми данными, которые мне нужны. Перехожу в JaCoCo, перезапускаю проверку — 100% методов покрыты тестами. Отлично! Напомню, что accuweatherClient я исключил из проверки, потому что его мы тестируем отдельно.

Finally 100%

Finally 100%
Код стал зеленым!

Код стал зеленым!

Вывод

Я продемонстрировал принципы и инструменты для написания и тестирования кода в Java с использованием модулей JUnit5, Mockito и AssertJ и проверку покрытия кода тестами с помощью JACoCo. Такой подход к проверке кода мы используем в Банки.ру. За счет чего обеспечиваем хорошее покрытия кода тестами и снижаем вероятность ошибок в продакшене. 

Что, нам мой взгляд, важно:

1. Использовать JaCoCo, чтобы проверить покрытие кода тестами и наглядно видеть, какие методы не были протестированы и что еще не покрыто тестами.

2. Не забывать проверять, как отрабатывают методы, которые ничего не возвращают

3. Проверять вызовы других методов на замокированных зависимостях.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Покрываете ли вы свой код тестами?

0% Да0
0% Нет0
0% Что такое тесты?0

Никто еще не голосовал. Воздержавшихся нет.

ссылка на оригинал статьи https://habr.com/ru/articles/814485/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *