Меня зовут И��орь Симаков, я тимлид 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). Захожу на сервисный слой, где есть разбивка по методам, и вижу, что ни один из них не покрыт. Значит, нужно заняться тестированием, чтобы достичь минимального порога.


Приступаем к тестированию
Создаем моки и спай с помощью 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 и вижу, что один метод теперь выделен зеленым цветом. Это означает, что я успешно протестировал конструктор и этот метод.
Покрытие кода повысилось, но еще недостаточно: еще есть методы, которые нужно протестировать. Поэтому тестирование продолжаю.

Тестируем метод getTopCityLocation с помощью ParameterizedTest.
Для демонстрации работы параметризованных тестов протестирую метод getTopCityLocation. Этот метод принимает на вход topCityCount — enum из трех элементов: 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% — стало лучше.


Тестируем 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"); }
Что проверяем в исключениях:
класс исключения является инстансом класса
ServiceException—isInstanceOf(ServiceException.class)сообщение, которое появляется при ошибке —
hasMessageContaining("Smthing go wrong!")стек трейс контейнер содержит наш сервис и наш метод —
hasStackTraceContaining("ru.simakov.com.service.AccuweatherService.callWithException")
После запуска возвращаемся в JaCoCO и вновь проверяем покрытие. Поднялось!

Тестируем основной метод 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 я исключил из проверки, потому что его мы тестируем отдельно.


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