Гайд по использованию 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: могу исключить из проверки пакеты, которые не хочу покрывать тестами. Например, покрытие модели данных. Также указываю минимальный порог тестирования, ниже которого сборка не пройдет.
org.jacoco
jacoco-maven-plugin
ru/example/com/model/**/*
ru/example/com/client/*
ru/example/com/utils/*
prepare-agent
prepare-agent
report
test
report
check-minimal
package
check
BUNDLE
INSTRUCTION
COVEREDRATIO
1.0
BRANCH
COVEREDRATIO
1.0
CLASS
MISSEDCOUNT
0
METHOD
MISSEDCOUNT
0
LINE
MISSEDCOUNT
0
Запускаю сборку своего сервиса, прогоняю тесты и вижу, что сборка падает: три класса не покрыты по строкам и по методам.
В результате проверки JaCoCo отдает HTMLку (можно найти в target/site/jacoco/index.html
). Захожу на сервисный слой, где есть разбивка по методам, и вижу, что ни один из них не покрыт. Значит, нужно заняться тестированием, чтобы достичь минимального порога.
Результат проверки 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
и вижу, что один метод теперь выделен зеленым цветом. Это означает, что я успешно протестировал конструктор и этот метод.
Покрытие кода повысилось, но еще недостаточно: еще есть методы, которые нужно протестировать. Поэтому тестирование продолжаю.
Результат повторной проверки JaCoCo
Тестируем метод 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% — стало лучше.
Покрытие увеличено до 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");
}
Что проверяем в исключениях:
класс исключения является инстансом класса
ServiceException
—isInstanceOf(ServiceException.class)
сообщение, которое появляется при ошибке —
hasMessageContaining("Smthing go wrong!")
стек трейс контейнер содержит наш сервис и наш метод —
hasStackTraceContaining("ru.simakov.com.service.AccuweatherService.callWithException")
После запуска возвращаемся в JaCoCO и вновь проверяем покрытие. Поднялось!
Метод 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%
Код стал зеленым!
Вывод
Я продемонстрировал принципы и инструменты для написания и тестирования кода в Java с использованием модулей JUnit5, Mockito и AssertJ и проверку покрытия кода тестами с помощью JACoCo. Такой подход к проверке кода мы используем в Банки.ру. За счет чего обеспечиваем хорошее покрытия кода тестами и снижаем вероятность ошибок в продакшене.
Что, нам мой взгляд, важно:
1. Использовать JaCoCo, чтобы проверить покрытие кода тестами и наглядно видеть, какие методы не были протестированы и что еще не покрыто тестами.
2. Не забывать проверять, как отрабатывают методы, которые ничего не возвращают
3. Проверять вызовы других методов на замокированных зависимостях.