Mockito с нуля: stubbing, verify, captor, spy и static mocking. Конспект

После изучения основ Junit логично перейти к другому фреймворку – Mockito. Он позволяет создавать подставные объекты (моки) для зависимостей тестируемого класса. Благодаря этому можно сосредоточиться на проверке конкретной логики, не затрагивая реальные сервисы, базы данных, API и другие внешние зависимости.

Добавляем Mockito в Java-проект. Первый тест

Так как я использую maven, то для добавления зависимости беру данные из репозитория на mvnrepository для mockito-core.

В pom.xml добавляю код для последней версии:

<!-- Source: https://mvnrepository.com/artifact/org.mockito/mockito-core -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>


Так как сам по себе Mockito не запускает тесты, а лишь отвечает за моки и прочие вещи, которые я опишу ниже в статье, использовать его нужно всё ещё с JUnit, и чтобы эти два фреймворка работали вместе, нужно также добавить зависимость Mockito JUnit Jupiter.

<!-- Source: https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>

Теперь можно написать первый тест:

public class UserServiceTest {
@Test
public void findUserById() {
UserRepository userRepositoryMock = mock(UserRepository.class);
User mockUser = new User("1", "Egor");
when(userRepositoryMock.findById("1")).thenReturn(mockUser);
UserService underTest = new UserService(userRepositoryMock);
User result = underTest.findUserById("1");
assertEquals(mockUser, result);
verify(userRepositoryMock).findById("1");
}
}

В этом тесте мы создаём мок репозитория, и с помощью методов when и thenReturn говорим, что в случае поиска по id = 1 возвращаем созданного юзера. То есть мы исключаем обращение к реальному репозиторию и базе данных. Такое описание поведения называется stubbing. После проверяем, действительно ли сервис возвращает нужного юзера.

Выглядит, на самом деле, немного странно. Мы сами создали пользователя, сами сказали вернуть такого же пользователя, а потом сравнили эти вроде бы одинаковые значения. Что же тут проверять? По факту, мы проверяем не результат из базы, а поведение сервиса: верно ли работает данный метод, не изменяет ли он объект. А с помощью verify подтверждаем, действительно ли он вызывает ожидаемый метод.

Как создаются моки?

Mockito не создаёт настоящий объект через new. Вместо этого он динамически (в рантайме) создаёт специальный proxy-объект (подкласс или реализацию интерфейса), который перехватывает вызовы методов, запоминает взаимодействия, возвращает значения, которые мы задали через stubbing.

Основные инструменты Mockito

Аннотации @Mock и @InjectMocks

В примере выше мы создавали репозиторий вручную в тесте. Чтобы этого не делать в каждом тесте или в @BeforeEach, можно просто передать эту задачу Mockito. Тогда достаточно над подобной зависимостью поставить аннотацию @Mock.

@Mock
private UserRepository repository;

Чтобы Mockito автоматически создавал эти зависимости, нужно над тестом поставить аннотацию @ExtendWith и указать наше расширение:

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
}

Теперь Mockito сам создаёт мок-объекты, внедряет их в тестовый класс, управляет их жизненным циклом и сбрасывает состояние между тестами.

Если тестируемый класс сам содержит зависимости, можно использовать @InjectMocks. Mockito автоматически найдёт все поля с @Mock и внедрит их в тестируемый объект.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repository;
@InjectMocks
private UserService userService;
}

Здесь:

repository – мок зависимости;

userService – тестируемый объект (object under test).

Важно: @InjectMocks работает только с классами, а не с интерфейсами.

Stubbing

При unit-тестировании важно проверять класс изолированно от его зависимостей. Для этого используется stubbing — задание заранее определённого поведения мока.

С помощью stubbing можно указать, что должен вернуть мок; при каких аргументах и как должен вести себя метод. Основной механизм stubbing в Mockito:

when(...).thenReturn(...)


В when указываем, вызов какого метода из мока будем заменять, а в thenReturn говорим, что в этом случае нужно вернуть. Это может быть, в том числе, и null. Например:

when(userRepositoryMock.findById("1")).thenReturn(mockUser);
when(userRepositoryMock.findById("2")).thenReturn(null);

Но эта конструкция работает лишь для методов, которые должны возвращать значение. Для void-методов есть другая конструкция:

doNothing().when(...)

Например:

doNothing().when(repository).deleteById("1");

По факту оно редко используется, так как void-методы и так ничего не делают в моках – они не лезут в настоящую базу и не вызывают реальную логику.

Также можно выкинуть исключение с помощью thenThrow для не void-методов:

when(...).thenThrow(...)

Для void-методов, по аналогии с doNothing, используется doThrow:

doThrow(...).when(...)

Обратите внимание, что с не void-методами в when вызов метода мока пишется в скобках, а для void-методов с doThrow и doNothing он ставится уже после when:

doThrow(new RuntimeException("DB error")).when(repository).deleteById("1");
when(repository.findById("1")).thenThrow(new RuntimeException("DB error"));

Mockito позволяет не только задавать поведение моков, но и проверять взаимодействие с ними. Для этого используется метод verify. В скобках ставится мок, а после точки пишем метод с нужными аргументами, вызов которого мы хотим подтвердить.

Так мы проверяем, действительно ли вызывается нужный метод: вызывает ли сервис репозиторий, контроллер – сервис, или приложение – внешний API.

По умолчанию verify ожидает ровно один вызов метода, но можно проверить и несколько взаимодействий с моком, таким образом для разных аргументов:

verify(repository).findById("1");
verify(repository).findById("2");

Если нужно проверить повторный вызов с тем же аргументом, то добавляем times вторым аргументом в verify.

verify(repository, times(3)).findById("1");

Также существуют:

never() – метод не должен вызываться

atLeast(n) – минимум n раз

atMost(n) – максимум n раз

Например:

verify(repository, never()).deleteById("1");

Матчеры аргументов

Иногда нужно проверить, что метод был не просто вызван, а с правильными аргументами. Для этого нужны матчеры (argument matcher).

verify(repository).save(any(User.class));

конкретное значение нас не интересует.

Практический пример: методы с таймстампами. Так как время меняется, то очень сложно «поймать» конкретный момент – скорее всего он изменится до проверки и тест упадёт. Поэтому здесь достаточно просто проверить тип аргумента, а не его значение.

transactionRepository.log(transactionId, amount, LocalDateTime.now());
verify(repository).log(eq("1"),eq(100),any(LocalDateTime.class));

Небольшая проблема лишь в политике Mockito: или все аргументы используют матчеры или никакие. Если всего лишь один аргумент использует матчер, то остальные оборачиваем в eq – по сути, равенство.

Список матчеров:

any() – любое значение

anyString() – любая строка

anyInt() – любое целое значение

any(User.class) – любое значение заданного типа

eq(value) – точное значение

isNull() – значение null

isNotNull() – не «пустое» значение

contains(«admin») – строка, которая содержит заданную подстроку

startsWith(«A») – строка, которая начинается с буквы

Матчеры абсолютно так же можно использовать при stubbing:

when(repository.findById(anyString())).thenReturn(mockUser);

Капторы аргументов

ArgumentCaptor – другой способ проверки аргументов. Они применяются как раз, когда нам важно точное значение и мы хотим достать его из вызванного метода.

Captor можно создать вручную:

ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);

Или через аннотацию:

@Captor
private ArgumentCaptor<LocalDateTime> captor;

Проверим timestamp:

verify(repository).log(eq("1"), eq(100), captor.capture());
LocalDateTime actualTimestamp = captor.getValue();


Метод capture перехватывает переданный аргумент, сохраняет его внутри captor и после этого можно получить значение. Теперь можно выполнять полноценные проверки:

assertNotNull(actualTimestamp);
assertTrue( actualTimestamp.isAfter(startTime));


Рассмотрим ещё пример с сервисом пользователей, который проводит регистрацию, но ничего не возвращает из метода register:

public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public void register(String name) {
User user = new User();
user.setName(name);
repository.save(user);
}
}

Тогда тест с каптором будет таким:

@Test
void registerTest() {
UserRepository repository = mock(UserRepository.class);
UserService service = new UserService(repository);
service.register("Egor");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("Egor", savedUser.getName());
}

Мы сначала готовим мок, вызываем нужный метод, а уже потом создаём каптор. Результат взаимодействия с методом сохраняется Mockito, поэтому в verify мы можем его перехватить и потом работать с ним.

Кроме метода getValue есть ещё getAllValues на случай, если мы достаём несколько значений из нескольких верификаций:

verify(repository, times(3)).save(captor.capture());
List<User> users = captor.getAllValues();

Тестирование статических методов с Mockito

Мы уже разобрались с разными методами, включая void, но что делать со статическими? В ранних версиях это было несколько сложно, потому что использовалась библиотека PowerMockito, которая была не очень совместима с JUnit 5. К счастью, в новых версиях сделать это намного легче. Для мокирования static-методов теперь используется объект MockedStatic.

MockedStatic<ClassName> mock = mockStatic(ClassName.class)

Почти всегда это оборачивается в try-with-resources. Если этого не сделали, то нужно вручную закрыть мок по завершении теста:

mock.close();

Но нужно понимать, что тестирование статических методов может быть опасным, так как мы не тестируем реальную логику, а только работу самого мока. Полезно это может быть, когда как раз реальная логика нас не интересует. Рассмотрим пример с сервисом заказов.

public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void createOrder(String name) {
Order order = new Order();
order.setName(name);
order.setCreatedAt(LocalDateTime.now());
repository.save(order);
}
}
@Test
void createOrderTest() {
OrderRepository repository =mock(OrderRepository.class);
OrderService service =new OrderService(repository);
LocalDateTime fixedTime = LocalDateTime.of(2026, 5,6,12, 0 );
try (MockedStatic<LocalDateTime> mockedTime = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) {
mockedTime.when(LocalDateTime::now).thenReturn(fixedTime);
service.createOrder("Laptop");
ArgumentCaptor<Order> captor =ArgumentCaptor.forClass(Order.class);
verify(repository).save(captor.capture());
Order savedOrder = captor.getValue();
assertEquals(fixedTime,savedOrder.getCreatedAt()
);
}
}

Здесь мы говорим, что при вызове статичного метода now нужно всегда возвращать фиксированное значение, чтобы избежать падения теста – ведь время всё время меняется. Поэтому в итоге перехваченное значение из каптора должно совпадать с заданным нами.

Режимы поведения mock-объектов

CALLS_REAL_METHODS говорит, чтобы вызывались реальные методы класса, если мы не переопределили их поведение.

Это нужно, чтобы нечаянно не «сломать» временно работу всего класса. Если бы мы не поставили этот аргумент, то даже LocalDateTime.of(…) стал бы возвращать null (так как его поведение мы не переопределили), а нам такое не нужно.

Этот аргумент чаще всего применяется для моков статических методов (для обычных моков используют spy – о нём ниже), и также есть другие режимы:

RETURNS_DEFAULTS – поведение по умолчанию, возвращает null, 0, false для методов.

UserRepository repository = mock(UserRepository.class);
String name = repository.getName();

Здесь name будет null, и если мы захотим что-то с ним делать, получим исключение. Чтобы получить больше информации в этом исключении, а не стандартный NullPointerException, можно выбрать более умный режим.

RETURNS_SMART_NULLS – более умные null для дебага.

Тогда мы получим подробное сообщение об ошибке

SmartNullPointerException:
You have a NullPointerException because
this method call was not stubbed:
repository.getName()

RETURNS_DEEP_STUBS – для вызовов по цепочке.

Например, у нас есть вызов:

user.getAddress().getCity().getName()

Если user мок, то getAddress вернёт null и дальше будет NullPointerException.

Обычно нам пришлось бы мокать всё вручную:

Address address = mock(Address.class);
City city = mock(City.class);
when(user.getAddress()).thenReturn(address);
when(address.getCity()).thenReturn(city);
when(city.getName()).thenReturn("London");

Решение через RETURNS_DEEP_STUBS

User user = mock(User.class,RETURNS_DEEP_STUBS);
when(user.getAddress().getCity().getName()).thenReturn("London");

Mockito автоматически создаёт промежуточные моки для Address, City и всей цепочки. Но возвращать моки моками считается плохой практикой, поэтому такой момент может быть доказательством плохой архитектуры.

«Шпионы» Spy

Помимо обычных mock-объектов, Mockito поддерживает spy – частичный мок.

Если обычный mock полностью заменяет объект и не вызывает реальные методы, то spy использует настоящую реализацию класса, позволяя переопределять отдельные методы.

UserService userService = spy(new UserService(repository));

Теперь реальные методы UserService будут вызываться, но некоторые из них можно замокать отдельно.

doReturn(mockUser).when(userService).findUserById("1");

Теперь findUserById(«1») вернёт mockUser; но остальные методы userService продолжат работать как настоящие.

Для обычных mock-объектов часто используется when, но для spy безопаснее использовать doReturn. Потому что when у spy может вызвать реальный метод уже во время настройки теста, а doReturn позволяет избежать этого при stubbing.

Spy полезен, когда нужна настоящая логика объекта, но некоторые методы нужно изолировать или контролировать отдельно.

Какая разница между Spy и CALLS_REAL_METHODS?

Spy оборачивает уже созданный реальный объект, созданный с помощью new. По умолчанию он вызывает настоящие методы, но отдельные методы можно переопределить через stubbing.

А во втором случае мы получаем мок, и наоборот, для него вызываем некоторые реальные методы. То есть CALLS_REAL_METHODS не инициализирует поля, так как он не вызывает конструктор класса.

public class CounterService {
private int counter = 5;
public int increment() {
counter++;
return counter;
}
}

Здесь при spy мы инициализируем counter и получаем верное значение (6):

CounterService service = spy(new CounterService());
System.out.println(service.increment());

А здесь получаем значение int по умолчанию (0), и вывод будет 1, что нарушает нашу логику:

CounterService service = mock(CounterService.class,CALLS_REAL_METHODS);
System.out.println(service.increment());

Поэтому чаще используют spy, а второй вариант оставляют для статических методов и особых случаев.

Тестирование асинхронного кода с Mockito

И напоследок разберёмся с асинхронным кодом, который всё чаще встречается сейчас в многопоточных приложениях с вызовами API.

Для работы с асинхронными операциями в Java часто используется CompletableFuture. Он представляет собой будущий результат асинхронной операции. Например:

public CompletableFuture<User> findUserById(String id) {
return CompletableFuture.supplyAsync(() ->
repository.findById(id)
);
}

Метод supplyAsync(…) запускает переданный код асинхронно в отдельном потоке. То есть вычисление продолжается параллельно, и результат появится позже.

Рассмотрим пример:

@Test
void findUserByIdTest() throws Exception {
UserRepository repository = mock(UserRepository.class);
UserService service = new UserService(repository);
User mockUser = new User("1", "Egor");
when(repository.findById("1")).thenReturn(mockUser);
CompletableFuture<User> future = service.findUserById("1");
User result = future.get();
assertNotNull(result);
assertEquals("Egor", result.getName());
verify(repository).findById("1");
}

Здесь мы создаём мок, настраиваем stubbing, а потом запускаем асинхронный метод. В этот момент создаётся новый поток, и поиск пользователя выполняется асинхронно.

Метод get() останавливает текущий поток; ждёт завершения async-операции и потом возвращает результат.

Если написать CompletableFuture<User> future = service.findUserById(«1») и сразу завершить тест, то async-код может ещё не выполниться. Поэтому нужно дождаться завершения.

get() может выбрасывать checked exceptions: InterruptedException или ExecutionException. Поэтому обычно используют либо throws Exception, либо try/catch, либо более современный вариант – join()

future.join()

join() бросает runtime exceptions. Поэтому в тестах он часто удобнее.

Mockito + CompletableFuture часто используются при тестировании спринга, веб-клиентов, Kafka consumers, внешних API и при параллельной обработке данных.

В остальном работа с асинхронным кодом в тестах Mockito ничем не отличается от обычного тестирования.

Оставить комментарий