Mockito: моки и стабы
Создал мок репозитория и, ничего не настраивая, вызвал его методы. Строковый вернул null, числовой - 0, логический - false, а метод со списком - ПУСТОЙ список, не null. Потом отдал этот мок сервису: сервис взял null и упал с NullPointerException.
Вот в этом вся тема. Мок по умолчанию отвечает пустотой, и код, который такой пустоты не ждёт, ломается прямо в тесте, а иногда, наоборот, проходит зелёным на данных, которых в жизни не бывает.
// Формулировки: «чем мок отличается от стаба и спая?», «что вернёт ненастроенный мок?», «что мокать, а что нет?», «почему тест с моком зелёный, а прод падает?».
Мок, стаб и спай
Мок - подставной объект, у которого нет никакой настоящей логики: он умеет отвечать заготовленным и запоминать, что его вызывали. Стаб - это про первую половину: заготовленные ответы. Проверка вызовов - про вторую: убедиться, что метод позвали, и с какими аргументами.
Спай - другое: настоящий объект, у которого подменяют отдельные методы. Мой замер: взял обычный список, добавил в него два элемента - размер честные 2. Подменил только метод размера на сотню - размер стал 100, а содержимое осталось настоящим, первый элемент на месте.
// Захват аргументов отвечает на вопрос «что именно ушло внутрь». Я вызвал сервис со строкой в пробелах, а перехватчик показал, что в репозиторий ушло уже обрезанное «Борис». Так проверяют преобразования, которые не видны в возвращаемом значении.
List<String> spy = spy(new ArrayList<>());
spy.add("первый"); spy.add("второй");
spy.size(); // 2 - настоящий
doReturn(100).when(spy).size();
spy.size(); // 100 - подменённый
spy.get(0); // "первый" - содержимое настоящее- мок / спай
- пустой подставной объект / настоящий с подменой отдельных методов
- захват аргументов
- показывает, что именно ушло в зависимость
Ответы по умолчанию и строгая проверка
Вернёмся к первому замеру. Ненастроенный мок отвечает нейтральным значением типа: null для объектов, ноль для чисел, false для логических. Коллекции - приятное исключение: возвращается пустая коллекция, а не null.
Отсюда два разных вреда. Первый - тест падает с NullPointerException, и ты полчаса ищешь ошибку в сервисе, хотя забыл настроить мок. Второй хуже: сервис спокойно переваривает пустой список, тест зелёный, а в жизни там никогда не бывает пусто. Проверка получилась ни о чём.
// Есть защита от третьей беды - настроек, которые никто не использует. В строгом режиме я настроил метод и не вызвал его, получив UnnecessaryStubbingException. Это ловит устаревшие настройки, оставшиеся после правок кода: они создают ложное ощущение, что тест что-то проверяет.
Repo mock = mock(Repo.class);
mock.findName(1); // null
mock.count(); // 0
mock.exists(1); // false
mock.all(); // [] - пустой список, НЕ null- ответ по умолчанию
- null, 0, false и пустая коллекция у ненастроенного мока
- лишняя настройка
- в строгом режиме падает: настроили метод и не вызвали
Что мокать, а что нет
Мокать стоит то, что делает тест медленным или непредсказуемым: обращения по сети, файловую систему, текущее время, случайные числа, тяжёлые внешние сервисы. Всё это заменяют предсказуемыми ответами.
Не стоит мокать собственную бизнес-логику и простые объекты с данными: заменив свой класс моком, ты проверяешь не поведение системы, а собственные представления о нём. Так рождаются тесты, которые ломаются от любой перестановки строк и не ловят ни одной настоящей ошибки.
// Отдельно про статические методы и время. Подменить статический метод можно, но только в пределах ограниченной области - я это проверял: внутри блока подставленное время вернулось, после выхода снова настоящее. Однако сама потребность мокать статику обычно означает, что зависимость стоит сделать явной: передавать источник времени параметром гораздо проще и для кода, и для теста.
- что мокать
- сеть, файлы, время, случайность - медленное и непредсказуемое
Как отвечать: «Тест с моком репозитория зелёный, а прод падает. Как так?»
Потому что мок отвечает так, как я его научил, а настоящий репозиторий - как устроена база. Три типовых расхождения. Первое: ненастроенный метод мока возвращает пустоту - null, ноль или пустой список; сервис на пустом списке отрабатывает штатно, тест зелёный, а в проде там всегда есть данные, и работает совсем другая ветка. Второе: мок не знает про ограничения базы - уникальные индексы, внешние ключи, длину поля; в тесте сохранение прошло, в проде упало на constraint. Третье: мок не воспроизводит ленивую загрузку связей и работу транзакции, поэтому LazyInitializationException появляется только на реальной базе. Вывод, который я делаю: моками закрываю логику вокруг зависимости, а сам слой доступа к данным проверяю интеграционным тестом на настоящей базе в контейнере.
Почему это сильный ответ: названы три разных механизма расхождения, а не общее «мок не настоящий», и сделан вывод про разделение зон ответственности между видами тестов.
На чём валят
- −Забыть настроить мок и искать ошибку в сервисе. Ненастроенный метод вернул null - я получал так NullPointerException прямо в тесте.
- −Радоваться зелёному тесту на пустом списке. В проде пусто не бывает, и проверена была не та ветка.
- −Мокать собственную бизнес-логику. Тест начинает проверять твои представления, а не поведение системы.
- −Оставлять настройки от прошлых версий кода. В строгом режиме это падает как лишняя настройка, и не зря.
- −Проверять только факт вызова, не глядя на аргументы. Захват аргументов показывает, что ушло внутрь на самом деле.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.
- Что проверяет verify(mock, times(2)).save(any())?A)Что метод save дважды вернул одно и то же значение вызывающему кодуB)Что у save ровно два перегруженных вариантаC)Что save отработал быстрее, чем за два тикаD)Что save был вызван на моке ровно два раза с любым аргументом
показать ответ и разбор
+D)Что save был вызван на моке ровно два раза с любым аргументом// разбор: verify проверяет ВЗАИМОДЕЙСТВИЕ: был ли вызов и сколько раз. times(2) требует ровно двух вызовов save с любым аргументом (any()). Есть never(), atLeast(n), atMost(n). Это проверка поведения-через-вызовы; злоупотребление ей (verify на каждый шаг) делает тест хрупким к рефакторингу.
- Чем spy отличается от mock в Mockito?A)spy оборачивает РЕАЛЬНЫЙ объект: незастабленные методы зовут настоящую реализациюB)spy — это мок без возможности верификацииC)spy умеет оборачивать только интерфейсы, тогда как mock применим исключительно к классамD)spy автоматически стабит все методы возвращать null
показать ответ и разбор
+A)spy оборачивает РЕАЛЬНЫЙ объект: незастабленные методы зовут настоящую реализацию// разбор: mock — полная заглушка (реальный код не исполняется, дефолты). spy оборачивает реальный экземпляр: незастабленные методы вызывают настоящую реализацию, а нужные точечно подменяются. spy берут для частичной подмены легаси-объекта. Тонкость: на spy стабят через doReturn(...).when(spy).m(), иначе реальный m() успеет выполниться.
- Почему эта строка не компилируется?
(deleteById возвращает void)when(mock.deleteById(1)) .thenReturn(null);A)thenReturn(null) здесь запрещён — void разрешает только thenReturn с объектомB)when нельзя вызвать на void-методе: у него нет значения для стабингаC)Аргумент 1 нужно обернуть в eq(1)D)deleteById надо пометить @Mockableпоказать ответ и разбор
+B)when нельзя вызвать на void-методе: у него нет значения для стабинга// разбор: when(mock.method()) принимает возвращаемое значение вызова — а у void его нет, поэтому конструкция не компилируется. Для void-методов используют do-синтаксис: doNothing().when(mock).deleteById(1), либо doThrow(ex).when(mock).deleteById(1) для проверки обработки ошибки. eq/матчеры тут ни при чём.
- Что делает @InjectMocks над полем orderService?A)Помечает orderService как мок, у которого все методы возвращают значение по умолчаниюB)Запрещает вызывать реальные методы orderService в тестеC)Создаёт реальный orderService и внедряет в него поля/аргументы, помеченные @MockD)Регистрирует orderService как бин в Spring-контексте
показать ответ и разбор
+C)Создаёт реальный orderService и внедряет в него поля/аргументы, помеченные @Mock// разбор: @InjectMocks создаёт РЕАЛЬНЫЙ тестируемый объект (SUT) и внедряет в него зависимости, объявленные как @Mock — предпочтительно через конструктор. Так граница юнита проходит по мокам-соседям, а логика самого сервиса исполняется по-настоящему. Со Spring это не связано — там подмену бинов делает @MockBean.
- Нужно проверить, что в repository.save() ушёл объект с status=PAID. Какой инструмент точнее всего?A)assertEquals(PAID, repo.getLastSaved().getStatus())B)verify(repo).save(any()) в расчёте, что переданный аргумент окажется правильнымC)Сделать repo spy и прочитать его внутреннее состояниеD)ArgumentCaptor: перехватить переданный save аргумент и заассертить его поля
показать ответ и разбор
+D)ArgumentCaptor: перехватить переданный save аргумент и заассертить его поля// разбор: ArgumentCaptor<Order> ловит фактический аргумент вызова: verify(repo).save(captor.capture()), затем captor.getValue() — и ассертим его поля (status=PAID). any() лишь подтвердит факт вызова, но не проверит содержимое. У мока нет getLastSaved, а превращать в spy ради этого — усложнение.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.