Меня зовут Игорь Симаков, я тимлид 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, чтобы проверить покрытие кода тестами и наглядно видеть, какие методы не были протестированы и что еще не покрыто тестами.
Не забывать проверять, как отрабатывают методы, которые ничего не возвращают
Проверять вызовы других методов на замокированных зависимостях.
Комментарии (3)
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%
imanushin
21.05.2024 21:43Не думаю, что эти if'ы лишние, так как, ну как мне кажется,
values
должно прийти извне, ну или иначе лучше сразу использовать значение из первого элемента.В
Stream
могут бытьnull
ы, ноOptional
прячет это.Почему jacoco считает их покрытыми - большой вопрос :)
Нет, с этим всё просто. В коде
a.b()
мы проверяем, чтоb()
было вызвано (то есть будет физический вызов функции), а в кодеa::b
мы проверяем, что мы получили указатель на функцию. И Jacoco честно говорит, что указатель был получен.
imanushin
Очень хорошая статья, спасибо. Неявно, Вы подсветили важный недостаток Java Streams, а именно - они накручивают покрытие тестами.
Давайте попробуем переписать Ваш код ниже:
Без Stream'ов (и Optional'ов) будет что-то вроде:
Это очень многословно, но, что самое интересно, на анализе покрытия тестами большинство условий будет подсвечено желтым, так как Вы проверяете только одну часть условия, но не вторую. Например, нет проверки того, что нам хоть когда-то не надо отправлять событие.
Более того, вот этот код покажет 100% покрытия если просто его вызвать:
Тогда как мой код выше будет весь "красным". И это важный момент, который я хотел подсветить:
.ifPresent(eventService::sendEvent)
отмечается как "вызванный" вне зависимости того, вызывали ли ВыsendEvent
или нет, так как здесь мы просто передаем ссылку на функцию (а она передается всегда).Если честно, я не знаю, как исправлять эту ситуацию в Java, так как это приведет к диссертации в каждом классе (по объему). В Kotlin, например, код был бы таким:
или:
И вот тут уже отсутствие вызова
eventService.sendEvent(it)
будет четко показано в JaCoCo.