JavaRush /Курсы /Spring Test /Базовый Mockito workflow

Базовый Mockito workflow

Spring Test
4 уровень , 1 лекция
Открыта

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: mockstubbingact → 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, тестовый метод перестаёт быть самодостаточным: читаешь его и не понимаешь, почему зависимость вернула именно это.

1
Задача
Spring Test, 4 уровень, 1 лекция
Недоступна
Stubbing и verify для решения по модерации
Stubbing и verify для решения по модерации
1
Задача
Spring Test, 4 уровень, 1 лекция
Недоступна
Исключение зависимости и отсутствие побочного эффекта
Исключение зависимости и отсутствие побочного эффекта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ