1. Mockito‑тест — это всё ещё JUnit‑тест
Когда впервые видишь Mockito, легко решить, что это какая-то «отдельная религия»: какие-то when, thenReturn, verify, а вокруг все шепчут про «магию прокси». На самом деле это просто инструмент внутри обычного JUnit‑теста. Он не отменяет Arrange–Act–Assert и не заменяет assertions. Mockito лишь помогает сделать зависимость предсказуемой и наблюдаемой.
В самом простом виде тест с Mockito выглядит как обычный сценарий: вы готовите окружение, делаете действие, проверяете результат. Mockito подключается ровно в двух местах: когда вы настраиваете ответы зависимости (stubbing) и когда вы проверяете, что внешний вызов действительно произошёл (verify).
Вот скелет, который полезно держать в голове: он хорошо спасает от хаоса в тестах.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SkeletonTest {
@Test
void scenario() {
// Arrange: подготовка данных и зависимостей
// (SUT — system under test — тоже обычно создаётся здесь)
// Act: действие
// Assert: проверка результата
// Assertions никуда не исчезают — Mockito их не заменяет
assertThat(true).isTrue();
}
}
Mockito этот скелет не меняет. Он просто добавляет «реквизит» в Arrange и иногда — дополнительные проверки в Assert.
2. Workflow: mock → stubbing → act → assertions → verify
У новичков чаще всего ломается не Mockito, а порядок действий: stubbing делают после вызова тестируемого метода, verify пишут без нормальных assertions, а половину важного прячут в общий @BeforeEach. В итоге тест читается как квест «найди, где тут вообще сценарий». Давайте соберём правильный порядок в одну понятную схему.
Почти любой unit‑тест с Mockito можно разложить так:
flowchart TD
A[Arrange: создать mock] --> B[Arrange: настроить stubbing]
B --> C[Act: вызвать метод SUT]
C --> D[Assert: проверить результат]
D --> E[Assert: verify важных взаимодействий]
В табличном виде (очень помогает при ревью тестов и при самопроверке):
| Шаг | Что делаем | Пример в Mockito |
|---|---|---|
| Arrange | Создаём зависимости‑замены | mock(ModerationClient.class) |
| Arrange | Настраиваем ответы | when(...).thenReturn(...) |
| Act | Вызываем тестируемый метод | service.submitForReview(...) |
| Assert | Проверяем результат/исключение | assertThat(...).isEqualTo(...) |
| Assert | Проверяем значимые эффекты | verify(sender).sendPublished(...) |
Главная дисциплина тут такая: stubbing всегда до Act, а verify обычно после assertions. Тогда тест выглядит как история, а не как «заклинания до и после».
3. Stubbing через when(...).thenReturn(...)
Stubbing — это когда вы заранее говорите: «Если зависимость получит вот такой вход, пусть вернёт вот такой результат». Это нужно не потому, что мы «не любим реальный код», а потому, что зависимость может быть медленной, нестабильной или вообще неуместной на этом уровне теста. Например, ModerationClient в ContentHub — это внешняя интеграция, и в unit‑тесте мы точно не хотим реальный HTTP.
Для примера возьмём упрощённую модель модерации, похожую на наш проект:
public enum ModerationVerdict {
OK, WARN, BLOCK
}
public interface ModerationClient {
ModerationVerdict moderate(String text);
}
В тесте мы создаём mock и задаём ему ответ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ModerationClientStubbingTest {
@Test
void stub_example() {
// Arrange: создаём подмену реальной зависимости
ModerationClient client = mock(ModerationClient.class);
// Arrange: stubbing — заранее описываем ожидаемое поведение зависимости
when(client.moderate("clean text")).thenReturn(ModerationVerdict.OK);
// Act + Assert: вызываем и проверяем, что вернулось то, что настроили
assertThat(client.moderate("clean text")).isEqualTo(ModerationVerdict.OK);
}
}
Да, этот пример «в вакууме» выглядит странновато: мы вызываем метод прямо у mock’а. В реальном тесте так делать не стоит — нам важнее тестировать наш сервис, а mock использовать как подложку. Но как первая демонстрация stubbing он очень удобен: видно, что when(...).thenReturn(...) — это просто настройка поведения.
Полезный нюанс для понимания: если метод mock’а не был застабблен, Mockito вернёт значение по умолчанию. Для объектов это обычно null. И вот тут начинаются первые «почему у меня NPE в тесте»: потому что вы ожидали «настоящий объект», а получили null, который вполне честно вернулся из mock’а.
4. Stubbing исключений: thenThrow(...)
В реальном backend‑коде многие проблемы приходят не через «неправильный результат», а через сбой зависимости: таймауты, недоступность сервиса, неожиданный формат ответа. И если в unit‑тесте вы пытаетесь воспроизвести это «по‑настоящему», то обычно получаете либо долго, либо нестабильно, либо вообще неповторяемо.
Mockito позволяет сказать: «Когда вызовут этот метод — брось исключение». Это и есть thenThrow(...).
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ExceptionStubbingTest {
@Test
void stubbing_exception_example() {
// Arrange: mock внешней зависимости
ModerationClient client = mock(ModerationClient.class);
// Arrange: моделируем сбой зависимости (например, таймаут)
when(client.moderate("any text"))
.thenThrow(new IllegalStateException("timeout"));
// Act + Assert: проверяем, что при вызове действительно будет исключение
assertThatThrownBy(() -> client.moderate("any text"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("timeout");
}
}
Снова важно помнить: напрямую вызывать mock в «настоящем» тесте не цель. Цель — уметь смоделировать сбой зависимости так, чтобы ваш тестируемый код (например, ArticleWorkflowService) показал, как он на это реагирует: пробрасывает исключение, переводит его в бизнес‑ошибку, не делает побочных эффектов и так далее.
Здесь важно не переборщить: thenThrow — мощная штука, но если вы начинаете через неё «рисовать кино» из десяти аварий подряд, тест становится театральным, а не полезным. Обычно достаточно одного конкретного сбоя на один конкретный сценарий.
5. verify(...): когда важен эффект
Есть методы, которые трудно проверить только по возвращаемому значению. Например, метод может возвращать void, но внутри отправлять уведомление, писать в репозиторий или звать внешнего клиента. В ContentHub это очень жизненная история: публикация статьи должна приводить к отправке уведомления (пусть даже сейчас это «адаптер», а не брокер сообщений).
В таких случаях verify — это способ сказать: «Тест доказывает, что вызов произошёл». Но важная оговорка: мы проверяем не «все внутренние движения», а именно внешне значимый эффект.
Сделаем мини‑пример, близкий к проекту:
public interface PublicationNotificationSender {
void sendPublished(long articleId, String slug);
}
public class ArticlePublisher {
private final PublicationNotificationSender sender;
public ArticlePublisher(PublicationNotificationSender sender) {
this.sender = sender;
}
public void publish(long articleId, String slug) {
// Важный внешний эффект: отправка уведомления
sender.sendPublished(articleId, slug);
}
}
И тест, который проверяет, что уведомление было отправлено:
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ArticlePublisherTest {
@Test
void publish_sends_notification() {
// Arrange: подменяем отправщика уведомлений
PublicationNotificationSender sender = mock(PublicationNotificationSender.class);
ArticlePublisher publisher = new ArticlePublisher(sender);
// Act: вызываем метод SUT
publisher.publish(42L, "java-mockito");
// Assert: проверяем внешний эффект (вызов зависимости с параметрами)
verify(sender).sendPublished(42L, "java-mockito");
}
}
Обратите внимание на приятную простоту: мы ничего не стаббим, потому что sendPublished ничего не возвращает. Нам важен сам факт вызова и параметры. И это как раз тот случай, где verify — не декоративная надпись, а смысл теста.
Ещё один важный момент: обычно полезнее сначала проверять результат (если он есть), а потом verify. Иначе легко получить «зелёный тест», который проверяет только то, что вы написали verify, но не проверяет, что метод вообще сделал правильный выбор.
Отрицательные проверки: never() и «не сделал» как часть правила
Иногда бизнес‑правило формулируется не как «сделай X», а как «в этом случае не делай X». И вот это «не делай» тоже важно уметь проверять, иначе регрессия будет выглядеть как «всё работает, просто почему-то пользователи получают уведомления о том, что их статью отклонили» (да, бывает).
Для таких сценариев в Mockito есть проверка never(): «этот метод не должен был быть вызван».
Представим правило из ContentHub: если модерация вернула BLOCK, то мы не должны отправлять уведомление о публикации. Да, в реальном проекте уведомление связано с publish, а не с submit, но нам сейчас важна сама техника.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class NegativeVerifyTest {
@Test
void rejected_article_does_not_send_published_notification() {
// Arrange: mock зависимости, за побочный эффект отвечает именно он
PublicationNotificationSender sender = mock(PublicationNotificationSender.class);
// Act: здесь в реальном тесте был бы вызов SUT, который принимает решение
// ... здесь мог бы быть вызов сервисного метода, который "отклонил" статью
// Assert: проверяем, что критичный побочный эффект НЕ произошёл
verify(sender, never()).sendPublished(1L, "any-slug");
}
}
Этот пример тоже нарочно «пустой» в части Act, чтобы сфокусироваться на синтаксисе. В реальном тесте вы, конечно, сначала вызовете метод сервиса, который решает судьбу статьи, а потом проверите, что уведомление не отправилось.
Важно: отрицательные проверки должны быть смысловыми. Если вы пытаетесь доказать, что никто никогда не вызвал ничего лишнего, вы быстро придёте к хрупкому тесту (и к грусти). Но если отсутствие вызова — часть контракта и бизнес‑поведения, never() подходит идеально.
times(n): сколько раз вызвали (и почему обычно достаточно «один раз»)
Проверка количества вызовов кажется очень «точной», и именно поэтому она так соблазняет: хочется проверить, что вызвали ровно два раза, три раза и ни разу больше. Но точность не всегда равна полезности. В unit‑тестах количество вызовов стоит проверять тогда, когда повторный вызов — это баг с реальными последствиями: двойная отправка уведомления, два списания денег, два создания вложения и так далее.
По умолчанию verify(mock).method(...) означает «вызвали один раз». Но иногда нам нужно указать число явно:
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
class TimesVerifyTest {
@Test
void can_verify_number_of_calls() {
// Arrange: mock, на котором будем считать вызовы
PublicationNotificationSender sender = mock(PublicationNotificationSender.class);
// Act: в реальности эти вызовы сделал бы SUT, тут — чисто для демонстрации
sender.sendPublished(1L, "a");
sender.sendPublished(1L, "a");
// Assert: проверяем, что вызов был ровно два раза
verify(sender, times(2)).sendPublished(1L, "a");
}
}
В живом тесте вы бы не вызывали метод зависимости руками — это делает тестируемый объект. Здесь мы снова показываем чистую механику.
Практическая мысль: если в вашем тесте times(n) появляется «просто потому что можно», остановитесь и спросите себя: «Что я реально защищаю?» Если вы защищаете «не отправлять дважды» — отлично. Если вы защищаете «мой метод в приватной функции не должен вызываться дважды», скорее всего, вы уже тестируете детали реализации, а не поведение.
6. Организация Mockito‑тестов
Когда проект растёт, хочется сократить шаблонный код. Mockito предлагает аннотации @Mock и автоматическую сборку объекта через @InjectMocks. Это удобно, но для новичка часто превращается в ситуацию «оно как-то собралось, а почему — я не понял». А непонятое в тестах обычно ведёт к неверным выводам и ложной уверенности.
Самый прозрачный и учебно‑полезный подход — явно собирать тестируемый объект через конструктор. Это идеально совпадает с нашим правилом в проекте: constructor injection only.
Пример с @BeforeEach (и обратите внимание: тут мы создаём mocks в setup, а stubbing оставляем внутри тестов):
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
class ManualSetupTest {
private ModerationClient moderationClient;
private ArticleStatusGateway statusGateway;
private ArticleWorkflowService service;
@BeforeEach
void setUp() {
// Arrange: создаём mocks один раз на тест (setup — для сборки окружения)
moderationClient = mock(ModerationClient.class);
statusGateway = mock(ArticleStatusGateway.class);
// Arrange: собираем SUT вручную, чтобы было понятно, откуда берутся зависимости
service = new ArticleWorkflowService(moderationClient, statusGateway);
}
@Test
void example() {
// stubbing — обычно тут, потому что он сценарный и зависит от конкретного теста
// здесь будет сценарий
}
}
А вот вариант с аннотациями — короче, но менее прозрачен:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class AnnotationStyleTest {
// Arrange: Mockito сам создаст mock-поля
@Mock ModerationClient moderationClient;
@Mock ArticleStatusGateway statusGateway;
// Arrange: Mockito попытается "вколоть" mocks в конструктор/поля SUT
@InjectMocks ArticleWorkflowService service;
@Test
void example() {
// stubbing/act/assert — по-прежнему пишем вручную, магии сценария тут нет
// здесь будет сценарий
}
}
Почему тут нужна осторожность? Потому что @InjectMocks может создавать ощущение, что зависимости «появляются сами». В реальном production‑коде такого как раз быть не должно. И ещё важный момент: @InjectMocks — это лишь локальное удобство для теста. Оно не отменяет рекомендацию проектировать SUT через constructor injection; field/setter injection не становится от этого нормой. В учебном режиме прозрачность почти всегда важнее экономии трёх строк.
7. Мини‑кейс ContentHub: submitForReview
Сейчас соберём маленький, но цельный пример, похожий на наш ContentHub. Здесь сразу видно SUT, ответ внешней зависимости, выбранную ветку бизнес-логики и осмысленный verify, поэтому на таком сценарии проще почувствовать весь Mockito workflow целиком. Нам нужна зависимость ModerationClient, которая даёт вердикт, и «шлюз» в persistence‑мир, который фиксирует новый статус. Мы специально не подключаем Spring и базу: это unit‑уровень, где нас интересуют правило и оркестрация, а не инфраструктура.
Упростим production‑код до минимума (но сохраним смысл):
public enum ArticleStatus {
DRAFT, IN_REVIEW, REJECTED
}
public interface ArticleStatusGateway {
void updateStatus(long articleId, ArticleStatus status);
}
public class ArticleWorkflowService {
private final ModerationClient moderationClient;
private final ArticleStatusGateway statusGateway;
public ArticleWorkflowService(ModerationClient moderationClient, ArticleStatusGateway statusGateway) {
this.moderationClient = moderationClient;
this.statusGateway = statusGateway;
}
public ArticleStatus submitForReview(long articleId, String body) {
// Внешняя зависимость: вердикт модерации приходит снаружи
ModerationVerdict verdict = moderationClient.moderate(body);
// Бизнес-правило: BLOCK -> REJECTED, иначе -> IN_REVIEW
ArticleStatus next = (verdict == ModerationVerdict.BLOCK)
? ArticleStatus.REJECTED
: ArticleStatus.IN_REVIEW;
// Внешний эффект: фиксируем новый статус
statusGateway.updateStatus(articleId, next);
return next;
}
}
Теперь — happy path: модерация OK → статус IN_REVIEW, и мы проверяем и результат, и взаимодействия:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ArticleWorkflowServiceTest {
@Test
void submitForReview_when_moderation_ok_moves_to_in_review() {
// Arrange: mocks зависимостей
ModerationClient moderationClient = mock(ModerationClient.class);
ArticleStatusGateway statusGateway = mock(ArticleStatusGateway.class);
// Arrange: собираем SUT вручную
ArticleWorkflowService service = new ArticleWorkflowService(moderationClient, statusGateway);
// Arrange: stubbing — как должна "ответить" внешняя модерация
when(moderationClient.moderate("clean")).thenReturn(ModerationVerdict.OK);
// Act: запускаем сценарий
ArticleStatus result = service.submitForReview(10L, "clean");
// Assert: проверяем бизнес-результат
assertThat(result).isEqualTo(ArticleStatus.IN_REVIEW);
// Assert: проверяем важные взаимодействия с внешним миром
verify(moderationClient).moderate("clean");
verify(statusGateway).updateStatus(10L, ArticleStatus.IN_REVIEW);
}
}
И негативный путь: модерация BLOCK → статус REJECTED:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ArticleWorkflowServiceBlockTest {
@Test
void submitForReview_when_moderation_block_moves_to_rejected() {
// Arrange: mocks зависимостей
ModerationClient moderationClient = mock(ModerationClient.class);
ArticleStatusGateway statusGateway = mock(ArticleStatusGateway.class);
// Arrange: SUT
ArticleWorkflowService service = new ArticleWorkflowService(moderationClient, statusGateway);
// Arrange: stubbing — модерация "блокирует" текст
when(moderationClient.moderate("spam")).thenReturn(ModerationVerdict.BLOCK);
// Act
ArticleStatus result = service.submitForReview(11L, "spam");
// Assert: проверяем выбранную ветку бизнес-логики
assertThat(result).isEqualTo(ArticleStatus.REJECTED);
// Assert: проверяем внешний эффект — статус действительно обновили
verify(statusGateway).updateStatus(11L, ArticleStatus.REJECTED);
}
}
Обратите внимание на стиль: stubbing локальный и видимый, Act один, assertions сначала про результат, verify — про действительно важные взаимодействия. Здесь нам нужно доказать и то, что статус обновили, и то, что модерация была вызвана.
8. Типичные ошибки при stubbing и verify
На этом этапе Mockito обычно ломается не «сложностью», а тем, что тест выглядит уверенно и зелёно, хотя на самом деле доказывает мало. Ошибки здесь коварные: тесты проходят, CI счастлив, а потом оказывается, что в production уходят неправильные аргументы или событие отправляется дважды. Разберём самые частые грабли спокойно и по делу.
Ошибка №1: stubbing после Act (или stubbing «на бегу»).
Если вы сначала вызвали метод сервиса, а потом написали when(...).thenReturn(...), вы настроили поведение уже после факта. В лучшем случае это просто не влияет на тест, в худшем — создаёт иллюзию, что зависимость «настроена», хотя реально она вернула null и ваш тест прошёл по случайной ветке.
Ошибка №2: тест проверяет только verify, но не проверяет результат.
Очень соблазнительно написать «главное, что метод вызвали» и не сделать assertions на результат. Тогда тест превращается в проверку сценария вызовов, а не поведения. Любое изменение, которое сохраняет те же вызовы, но ломает смысл (например, возвращает неправильный статус), может остаться незамеченным.
Ошибка №3: verify на каждый чих — и тест начинает проверять реализацию.
Проверять вызов внешнего эффекта полезно. Проверять каждый внутренний шаг — обычно нет. Когда вы фиксируете десять взаимодействий подряд, тест становится хрупким: малейший рефакторинг, который не меняет поведение, ломает его. В итоге тесты мешают развивать код и превращаются в «заморозку дизайна».
Ошибка №4: stubbing слишком широким поведением, которое пропускает ошибку.
Если зависимость «согласна на всё», тест легко проходит даже при неверных аргументах. Классический пример — чрезмерно универсальные заглушки, вроде «на любой текст верни OK». Даже без matchers можно устроить похожую проблему, если вы вообще не заботитесь о том, какой вход передали в mock и почему.
Ошибка №5: прятать сценарий в @BeforeEach (особенно stubbing).
Общий setup полезен для создания объектов. Но stubbing почти всегда сценарный: для одного теста модерация OK, для другого BLOCK, для третьего — исключение. Если вы спрятали stubbing в @BeforeEach, тестовый метод перестаёт быть самодостаточным: читаешь его и не понимаешь, почему зависимость вернула именно это.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ