Mockito: несколько ответов в одном тесте – последовательные вызовы
Для кого эта статья:
- Программисты и разработчики, работающие с Java и тестированием программного обеспечения
- Инженеры по автоматизации тестирования, заинтересованные в улучшении своих навыков в работе с Mockito
Студенты и профессионалы, обучающиеся основам и продвинутым техникам тестирования в Java-разработке
Представьте: вы пишете тесты для метода, который должен вести себя по-разному при последовательных вызовах. Например, сначала возвращать успешный результат, потом — ошибку, а затем пустое значение. Казалось бы, придётся писать три отдельных теста? Нет! Mockito предлагает элегантный способ имитировать различное поведение метода при каждом вызове в рамках одного теста. Эта возможность часто упускается из виду, но она может радикально упростить ваш тестовый код и сделать тесты более точными и компактными. 🔍
Освоить продвинутые техники тестирования, включая мокирование с настройкой поведения объектов, вы можете на Курсе Java-разработки от Skypro. Преподаватели-практики не только объяснят базовые принципы Mockito, но и поделятся реальными сценариями применения последовательных вызовов методов с разными ответами, которые сразу можно внедрить в рабочие проекты. Учитесь писать профессиональный код с правильными тестами!
Основы настройки последовательных вызовов в Mockito
Когда разрабатываемый компонент зависит от другого объекта с изменчивым поведением, тестирование может превратиться в настоящую головоломку. Особенно если метод, который вы вызываете, должен возвращать разные результаты при каждом обращении.
Mockito предоставляет решение этой проблемы через механизм последовательных заглушек (consecutive stubs). Этот подход позволяет указать, какие значения должен возвращать метод при первом, втором и последующих вызовах.
Базовый синтаксис выглядит следующим образом:
// Создаем мок
List<String> mockedList = mock(List.class);
// Настраиваем последовательные ответы
when(mockedList.get(0))
.thenReturn("Первый вызов")
.thenReturn("Второй вызов")
.thenReturn("Третий вызов");
// Использование:
System.out.println(mockedList.get(0)); // Выведет "Первый вызов"
System.out.println(mockedList.get(0)); // Выведет "Второй вызов"
System.out.println(mockedList.get(0)); // Выведет "Третий вызов"
System.out.println(mockedList.get(0)); // Снова выведет "Третий вызов"
Обратите внимание: после исчерпания указанной последовательности возвратов метод будет постоянно возвращать последнее значение. Это важный нюанс, который следует учитывать при проектировании тестов. 📝
Артём Соколов, Lead Java Developer
На одном из проектов мы столкнулись с тестированием сервиса аутентификации, который проверял токены через внешний API. Нам требовалось проверить, как система обрабатывает сценарий, когда сначала токен валиден, затем устаревает (получаем ошибку), а после обновления снова становится действительным.
Изначально у нас было три отдельных теста, но это создавало ненужное дублирование кода и не позволяло проверить весь поток операций целиком. После перехода на последовательное мокирование с помощью Mockito, мы объединили эти тесты в один, который намного лучше отражал реальное поведение системы:
JavaСкопировать кодwhen(tokenValidator.validate(anyString())) .thenReturn(true) // Первый вызов – токен валиден .thenReturn(false) // Второй вызов – токен просрочен .thenReturn(true); // Третий вызов – токен обновлен и снова валиденЭтот подход не только сократил код тестов на 40%, но и помог найти баг в логике обработки исключений, который не проявлялся в отдельных тестах.
Помимо краткой формы записи с цепочкой вызовов, существует также эквивалентный, но более явный вариант:
when(mockedList.get(0))
.thenReturn("Первый вызов", "Второй вызов", "Третий вызов");
Этот синтаксис более компактный и может быть предпочтительнее в случаях, когда список возвращаемых значений достаточно длинный.
Важно понимать основные принципы работы механизма последовательных заглушек:
- Соответствие аргументов: Последовательность срабатывает только когда метод вызывается с теми же аргументами, что и в настройке
- Счетчики вызовов: Mockito ведет отдельный счетчик для каждого настроенного вызова метода
- Независимость последовательностей: Разные настройки для разных методов или разных аргументов имеют отдельные последовательности
| Особенность | Описание | Пример |
|---|---|---|
| Аргументы матчеры | Можно использовать матчеры для более гибкой настройки | when(service.process(anyString())).thenReturn("A", "B") |
| Последний ответ | После последнего значения в цепочке будет возвращаться последний указанный результат | Для последовательности "A", "B" четвертый вызов вернет "B" |
| Комбинация с верификацией | Можно проверять факт и количество вызовов | verify(mock, times(3)).method() |

Использование thenReturn() для разных ответов мок-объекта
Метод thenReturn() — основной инструмент для настройки последовательных возвращаемых значений в Mockito. Он позволяет создать цепочку возвратов, которые будут использоваться при каждом последующем вызове метода. 🔄
Рассмотрим практический пример с сервисом погоды:
// Интерфейс сервиса погоды
public interface WeatherService {
String getWeatherForecast(String city);
}
// Тестируемый класс
public class WeatherReporter {
private final WeatherService weatherService;
public WeatherReporter(WeatherService weatherService) {
this.weatherService = weatherService;
}
public String getDailyReport(String city) {
String morningForecast = weatherService.getWeatherForecast(city);
String eveningForecast = weatherService.getWeatherForecast(city);
return "Утренний прогноз: " + morningForecast +
", Вечерний прогноз: " + eveningForecast;
}
}
Теперь напишем тест с последовательными возвратами:
@Test
public void testDailyReport() {
// Создаем мок сервиса погоды
WeatherService mockWeatherService = mock(WeatherService.class);
// Настраиваем последовательные ответы
when(mockWeatherService.getWeatherForecast("Москва"))
.thenReturn("Солнечно, 20°C")
.thenReturn("Облачно, 15°C");
// Создаем тестируемый объект с моком
WeatherReporter reporter = new WeatherReporter(mockWeatherService);
// Вызываем метод, который внутри дважды обращается к сервису
String report = reporter.getDailyReport("Москва");
// Проверяем результат
assertEquals("Утренний прогноз: Солнечно, 20°C, Вечерний прогноз: Облачно, 15°C", report);
}
Возможные варианты использования thenReturn() расширяют его применимость:
- Тестирование циклов: Настройка последовательных возвратов идеальна для тестирования методов, работающих в цикле
- Имитация изменяющихся состояний: Можно имитировать объект, меняющий свое состояние с каждым вызовом
- Тестирование кэширования: Проверка корректности кэширования, когда сервис должен обращаться к источнику данных только один раз
Сокращенная форма записи особенно удобна при большом количестве последовательных значений:
// Эквивалентные записи:
when(service.nextValue())
.thenReturn(1)
.thenReturn(2)
.thenReturn(3)
.thenReturn(4);
// Более компактная форма:
when(service.nextValue()).thenReturn(1, 2, 3, 4);
При работе с коллекциями и итерациями, последовательные возвраты могут сделать тесты более наглядными:
// Тестируем обработчик коллекции
@Test
public void testCollectionProcessor() {
DataProvider mockProvider = mock(DataProvider.class);
when(mockProvider.hasNext())
.thenReturn(true, true, true, false);
when(mockProvider.getNext())
.thenReturn("Первый элемент")
.thenReturn("Второй элемент")
.thenReturn("Третий элемент");
CollectionProcessor processor = new CollectionProcessor(mockProvider);
List<String> results = processor.processAll();
assertEquals(3, results.size());
assertEquals("Первый элемент", results.get(0));
assertEquals("Второй элемент", results.get(1));
assertEquals("Третий элемент", results.get(2));
}
| Сценарий использования | Применение последовательных возвратов | Преимущества |
|---|---|---|
| Эмуляция состояний | when(service.getState()).thenReturn("INIT", "PROCESSING", "COMPLETE") | Тестирование переходов между состояниями в одном тесте |
| Пагинация | when(repo.getPage()).thenReturn(page1, page2, null) | Удобное тестирование обработки страниц данных |
| Кэширование | when(cache.get()).thenReturn(null, cachedValue) | Проверка логики заполнения и использования кэша |
| Retry-логика | when(service.call()).thenThrow(exception).thenReturn(value) | Тестирование повторных попыток после ошибки |
Имитация исключений с thenThrow() при многократных вызовах
Метод thenThrow() позволяет имитировать выбрасывание исключений при вызовах мок-объекта. Этот инструмент особенно полезен при тестировании механизмов обработки ошибок, retry-логики и граничных случаев. В контексте последовательных вызовов, thenThrow() можно комбинировать с thenReturn(), создавая сложные сценарии поведения. 💥
Базовый синтаксис аналогичен тому, что мы рассмотрели ранее:
// Мок выбросит исключение при первом вызове, затем вернет "value"
when(mock.someMethod())
.thenThrow(new RuntimeException("Ошибка соединения"))
.thenReturn("Успешное соединение");
Рассмотрим практический пример тестирования системы с retry-логикой:
public class DatabaseConnector {
private final DataSource dataSource;
public DatabaseConnector(DataSource dataSource) {
this.dataSource = dataSource;
}
public String fetchDataWithRetry(String query, int maxRetries) {
int attempts = 0;
while (attempts <= maxRetries) {
try {
return dataSource.executeQuery(query);
} catch (Exception e) {
attempts++;
if (attempts > maxRetries) {
throw new RuntimeException("Превышено количество попыток", e);
}
// Небольшая задержка перед повторной попыткой
try {
Thread.sleep(10);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
return null; // Недостижимый код, но требуется для компиляции
}
}
Тест для проверки механизма повторных попыток:
@Test
public void testFetchWithRetry_SucceedsAfterFailures() {
// Создаем мок источника данных
DataSource mockDataSource = mock(DataSource.class);
// Настраиваем: первые две попытки завершатся ошибкой, третья успешна
when(mockDataSource.executeQuery("SELECT * FROM users"))
.thenThrow(new SQLException("Ошибка сети"))
.thenThrow(new SQLException("Таймаут соединения"))
.thenReturn("Данные пользователей");
DatabaseConnector connector = new DatabaseConnector(mockDataSource);
// Вызываем метод с максимум 3 попытками
String result = connector.fetchDataWithRetry("SELECT * FROM users", 3);
// Проверяем, что третья попытка была успешной
assertEquals("Данные пользователей", result);
// Проверяем, что метод был вызван ровно 3 раза
verify(mockDataSource, times(3)).executeQuery("SELECT * FROM users");
}
Можно также тестировать ситуации, когда все попытки завершаются неудачей:
@Test
public void testFetchWithRetry_FailsAfterAllAttempts() {
DataSource mockDataSource = mock(DataSource.class);
// Все попытки будут неудачными
when(mockDataSource.executeQuery(anyString()))
.thenThrow(new SQLException("Ошибка 1"))
.thenThrow(new SQLException("Ошибка 2"))
.thenThrow(new SQLException("Ошибка 3"));
DatabaseConnector connector = new DatabaseConnector(mockDataSource);
// Проверяем, что после всех попыток метод выбросит исключение
assertThrows(RuntimeException.class, () -> {
connector.fetchDataWithRetry("SELECT * FROM users", 2);
});
// Проверяем, что было сделано ровно 3 попытки
verify(mockDataSource, times(3)).executeQuery(anyString());
}
Ключевые особенности использования thenThrow() в последовательных вызовах:
- Типы исключений: Можно использовать разные типы исключений в последовательности
- Комбинация с thenReturn(): Чередование исключений и нормальных возвратов позволяет моделировать сложные сценарии
- Класс vs экземпляр: Можно передавать как экземпляры исключений, так и классы (Mockito создаст экземпляры)
- Проверимые исключения: Работает и с проверяемыми (checked) исключениями
Пример с разными типами исключений и чередованием с обычными возвратами:
when(service.process())
.thenReturn("Первый успех")
.thenThrow(new IOException("Сетевая ошибка"))
.thenReturn("Второй успех")
.thenThrow(SQLException.class); // Используем класс вместо экземпляра
Михаил Петров, QA Lead
При разработке платежной системы мы столкнулись с необходимостью тщательно тестировать процессинговый модуль, который должен был обрабатывать временные сбои во внешнем API.
Интересный случай произошел, когда мы тестировали стратегию экспоненциальной задержки между повторными попытками. Согласно требованиям, система должна была делать до пяти попыток с увеличивающимися интервалами.
Мы настроили мок для имитации различных ошибок на каждой попытке:
JavaСкопировать кодwhen(paymentGateway.processPayment(any())) .thenThrow(new GatewayTimeoutException("Превышено время ожидания")) .thenThrow(new ServiceUnavailableException("Сервис недоступен")) .thenThrow(new RateLimitException("Превышен лимит запросов")) .thenThrow(new NetworkException("Ошибка соединения")) .thenReturn(new PaymentResponse("SUCCESS", "12345"));Благодаря этому подходу мы обнаружили ошибку в логике обработки исключений: наш код правильно обрабатывал все ошибки, кроме RateLimitException, для которой нужно было использовать особую стратегию задержки.
Без возможности моделировать последовательность различных исключений в одном тесте, мы бы никогда не поймали эту ошибку так быстро. Мокирование последовательности исключений сэкономило нам недели тестирования и предотвратило потенциальные проблемы в продакшене.
Техники doReturn() и doAnswer() для сложного поведения мока
Методы doReturn() и doAnswer() представляют собой альтернативный синтаксис настройки поведения моков в Mockito. Они особенно полезны в ситуациях, когда традиционный подход с when().thenReturn() не подходит или требуется более сложная логика. 🧩
Основные сценарии, требующие использования doReturn():
- Мокирование void-методов: Когда нужно настроить поведение методов, не возвращающих значения
- Шпионы (spies): При работе со шпионами для избежания вызова реального метода
- Проблемы с перегрузкой методов: Когда компилятор не может однозначно определить, какой перегруженный метод используется
Базовый синтаксис doReturn() для последовательных вызовов:
// Последовательные возвраты с doReturn
doReturn("Первый")
.doReturn("Второй")
.doReturn("Третий")
.when(mock).someMethod();
Пример использования doReturn() со шпионом:
@Test
public void testWithSpy() {
// Создаем шпиона для реального объекта
List<String> realList = new ArrayList<>();
List<String> spy = spy(realList);
// Неправильно: вызовет реальный метод get(),
// что приведет к IndexOutOfBoundsException
// when(spy.get(0)).thenReturn("first");
// Правильно: обходим вызов реального метода
doReturn("first").when(spy).get(0);
doReturn("second").when(spy).get(1);
assertEquals("first", spy.get(0));
assertEquals("second", spy.get(1));
}
Метод doAnswer() предоставляет еще большую гибкость, позволяя определить пользовательскую логику ответа. Это особенно полезно, когда нужно, чтобы ответ зависел от переданных аргументов или имел побочные эффекты.
Для реализации последовательности ответов с doAnswer(), можно использовать счетчик вызовов:
@Test
public void testWithDoAnswerSequence() {
Counter counter = mock(Counter.class);
final AtomicInteger calls = new AtomicInteger(0);
// Настраиваем разное поведение в зависимости от номера вызова
doAnswer(invocation -> {
int callNumber = calls.getAndIncrement();
switch(callNumber) {
case 0: return 10;
case 1: return 20;
case 2: return 30;
default: return 40;
}
}).when(counter).getCount();
assertEquals(10, counter.getCount()); // Первый вызов
assertEquals(20, counter.getCount()); // Второй вызов
assertEquals(30, counter.getCount()); // Третий вызов
assertEquals(40, counter.getCount()); // Четвертый вызов
}
Кроме того, doAnswer() позволяет имитировать сложное поведение, например, модифицировать аргументы или зависеть от состояния:
@Test
public void testComplexBehavior() {
DataProcessor processor = mock(DataProcessor.class);
// Мок будет модифицировать переданные данные
doAnswer(invocation -> {
byte[] data = invocation.getArgument(0);
// Инвертируем каждый байт в массиве
for (int i = 0; i < data.length; i++) {
data[i] = (byte) ~data[i];
}
return data.length;
}).when(processor).process(any(byte[].class));
byte[] testData = {1, 2, 3};
int result = processor.process(testData);
assertEquals(3, result);
assertArrayEquals(new byte[]{-2, -3, -4}, testData);
}
Сравнение методов настройки последовательных вызовов:
| Метод | Преимущества | Недостатки | Основные сценарии использования |
|---|---|---|---|
| when().thenReturn() | Лаконичный синтаксис, простота использования | Не работает с void-методами, проблемы со шпионами | Стандартные сценарии мокирования |
| doReturn().when() | Работает с void-методами и шпионами | Менее интуитивный синтаксис | Void-методы, шпионы, перегруженные методы |
| doAnswer().when() | Максимальная гибкость, динамические ответы | Более многословный, сложнее поддерживать | Сложная логика, зависящая от аргументов или состояния |
| doThrow().when() | Работает с void-методами | Менее интуитивный синтаксис | Имитация исключений в void-методах |
Практические сценарии и типичные ошибки при настройке моков
При использовании последовательных вызовов в Mockito разработчики часто сталкиваются с определенными сценариями и ошибками. Рассмотрим наиболее распространенные из них и способы их решения. ⚠️
Сценарий 1: Моделирование пагинации
При тестировании компонента, который обрабатывает данные страницами, последовательные возвраты незаменимы:
@Test
public void testPagination() {
// Настраиваем мок для имитации постраничных данных
DataProvider provider = mock(DataProvider.class);
when(provider.fetchPage(eq(1)))
.thenReturn(Arrays.asList("Элемент 1", "Элемент 2"));
when(provider.fetchPage(eq(2)))
.thenReturn(Arrays.asList("Элемент 3", "Элемент 4"));
when(provider.fetchPage(eq(3)))
.thenReturn(Collections.emptyList()); // Пустая страница – конец данных
// Тестируем компонент, который извлекает и обрабатывает все страницы
DataProcessor processor = new DataProcessor(provider);
List<String> allProcessedData = processor.processAllPages();
assertEquals(4, allProcessedData.size());
}
Сценарий 2: Тестирование конечного автомата
Для объектов, которые меняют свое состояние при вызовах методов:
@Test
public void testStateMachine() {
StateMachine mock = mock(StateMachine.class);
// Настраиваем переходы между состояниями
when(mock.getState())
.thenReturn("INITIAL")
.thenReturn("PROCESSING")
.thenReturn("PROCESSING")
.thenReturn("COMPLETED");
StateMachineProcessor processor = new StateMachineProcessor(mock);
processor.process();
// Проверяем, что процессор правильно обработал все состояния
verify(mock, times(4)).getState();
}
Типичные ошибки при настройке последовательных вызовов:
- Игнорирование аргументов: Настройка без учета аргументов или с неправильными матчерами
- Проблемы порядка настройки: Более специфичные настройки перекрываются более общими
- Забытые счетчики вызовов: Неправильное понимание того, как именно отслеживаются вызовы
- Проблемы с типами возвращаемых значений: Несоответствие типов в цепочке thenReturn
Пример ошибки с порядком настройки:
// Неправильно: более общая настройка перекрывает специфичную
when(service.process(anyString())).thenReturn("Общий ответ");
when(service.process("specific")).thenReturn("Специальный ответ");
// Правильно: более специфичная настройка должна идти первой
when(service.process("specific")).thenReturn("Специальный ответ");
when(service.process(anyString())).thenReturn("Общий ответ");
Как избежать проблем с последовательными вызовами:
- Используйте строгие моки: Настройка
mock(Class.class, STRICT_STUBS)поможет найти неиспользуемые заглушки - Проверяйте количество вызовов: Используйте
verify(mock, times(N))для подтверждения ожидаемого числа вызовов - Сброс моков: Для сложных тестов используйте
reset(mock)перед новой настройкой (но с осторожностью) - Применяйте аргумент-капторы:
ArgumentCaptorпоможет проверить, с какими аргументами вызывался метод
Пример правильной проверки вызовов:
@Test
public void testVerification() {
DataService service = mock(DataService.class);
when(service.getData())
.thenReturn("A")
.thenReturn("B")
.thenReturn("C");
assertEquals("A", service.getData());
assertEquals("B", service.getData());
// Проверяем, что метод был вызван ровно дважды
verify(service, times(2)).getData();
// Вызываем еще раз и проверяем результат
assertEquals("C", service.getData());
// Проверяем общее количество вызовов
verify(service, times(3)).getData();
}
Практический совет: если метод должен возвращать одно и то же значение много раз, а затем изменить поведение, удобнее использовать подход с явным счетчиком:
@Test
public void testWithCounter() {
Service service = mock(Service.class);
// Для первых 5 вызовов возвращаем true, затем false
AtomicInteger counter = new AtomicInteger();
when(service.isValid()).thenAnswer(inv -> counter.getAndIncrement() < 5);
// Проверяем поведение
for (int i = 0; i < 5; i++) {
assertTrue(service.isValid());
}
assertFalse(service.isValid());
}
Mockito предлагает мощный инструментарий для создания последовательных заглушек, позволяющий точно имитировать сложное поведение зависимостей в тестах. От базовых сценариев с thenReturn() до продвинутых комбинаций с doAnswer(), эти механизмы дают возможность писать более элегантные, точные и компактные тесты. Освоив техники настройки многократных вызовов методов с разными ответами, вы сможете не только улучшить покрытие тестами, но и смоделировать те сценарии, которые ранее требовали сложной подготовки тестовых данных или даже невозможно было протестировать.