Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров3.9K

Меня зовут Игорь Симаков, я тимлид 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. Проверять вызовы других методов на замокированных зависимостях.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Покрываете ли вы свой код тестами?
81.82% Да18
9.09% Нет2
9.09% Что такое тесты?2
Проголосовали 22 пользователя. Воздержался 1 пользователь.
Теги:
Хабы:
+6
Комментарии3

Публикации

Информация

Сайт
www.banki.ru
Дата регистрации
Дата основания
2005
Численность
501–1 000 человек
Местоположение
Россия