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

Комментарии (3)


  1. imanushin
    21.05.2024 21:43
    +5

    Очень хорошая статья, спасибо. Неявно, Вы подсветили важный недостаток Java Streams, а именно - они накручивают покрытие тестами.

    Давайте попробуем переписать Ваш код ниже:

    Stream.of(50, 100, 150)
           .findAny()
           .map(TopCitiesCount::findByValue)
           .map(this::getTopCityLocation)
           .map(this::getCurrentConditionByLocation)
           .ifPresent(eventService::sendEvent)
    

    Без Stream'ов (и Optional'ов) будет что-то вроде:

    final var values = List.of(50, 100, 150);
    
    if(values.isEmpty()) {
       return
    }
    final var firstElement = valies.get(0);
    if(firstElement == null) {
       return;
    }
    final var topCity = TopCitiesCount.findByValue(firstElement);
    if(topCity == null) {
       return;
    }
    final var location = topCity.getTopCityLocation();
    if(location == null) {
       return
    }
    final var currentCondition = location.getCurrentConditionByLocation();
    if(currentCondition == null) {
      return;
    }
    eventService.sendEvent(currentCondition);
    

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

    Более того, вот этот код покажет 100% покрытия если просто его вызвать:

    Stream.of()
           .findAny()
           .map(TopCitiesCount::findByValue)
           .map(this::getTopCityLocation)
           .map(this::getCurrentConditionByLocation)
           .ifPresent(eventService::sendEvent)
    

    Тогда как мой код выше будет весь "красным". И это важный момент, который я хотел подсветить: .ifPresent(eventService::sendEvent) отмечается как "вызванный" вне зависимости того, вызывали ли Вы sendEvent или нет, так как здесь мы просто передаем ссылку на функцию (а она передается всегда).

    Если честно, я не знаю, как исправлять эту ситуацию в Java, так как это приведет к диссертации в каждом классе (по объему). В Kotlin, например, код был бы таким:

    val values = listOf(50, 100, 150);
    val condition = values
                .firstOrNull()
                ?.findByValue()
                ?.topCityLocation
                ?.currentConditionByLocation
    if(condition != null) {
        eventService.sendEvent(condition)
    }
    

    или:

    val values = listOf(50, 100, 150);
    values
        .firstOrNull()
        ?.findByValue()
        ?.topCityLocation
        ?.currentConditionByLocation
        ?.let {
           eventService.sendEvent(it)
        }
    

    И вот тут уже отсутствие вызова eventService.sendEvent(it) будет четко показано в JaCoCo.


  1. igoresha_s Автор
    21.05.2024 21:43
    +1

    Спасибо ко комментарий! Из вашего кода в примере видятся лишние if'ы

    //Первый, который всегда будет false
    if(values.isEmpty()) {
       return
    }
    //Второй, который всегда будет false
    if(firstElement == null) {
       return;
    }

    Остальные альтернативные сценарии возможны. Их нужно тестировать отдельно: мокать вызов клиента и выкидывать ошибку. Имеется ввиду сценарии orElseThrow . Почему jacoco считает их покрытыми - большой вопрос :)

    И конечно, еще нужен альтернативный сценарий, когда метод eventService.sendEvent не вызовется по бизнес логике. Данные сценарии я специально не отражал в туториале, он и так получился довольно большой(

    Вы правы на счет того, что jacoco не отслеживает покрытие ветки .ifPresent(eventService::sendEvent). По идее, здесь по бранчам покрыто 50%, хотя при генерации отчета index.html отображается 100%


    1. imanushin
      21.05.2024 21:43

      Не думаю, что эти if'ы лишние, так как, ну как мне кажется, values должно прийти извне, ну или иначе лучше сразу использовать значение из первого элемента.

      В Stream могут быть nullы, но Optional прячет это.

      Почему jacoco считает их покрытыми - большой вопрос :)

      Нет, с этим всё просто. В коде a.b() мы проверяем, что b() было вызвано (то есть будет физический вызов функции), а в коде a::b мы проверяем, что мы получили указатель на функцию. И Jacoco честно говорит, что указатель был получен.