JavaRush /Курси /Spring Test /Базовий робочий процес Mockito

Базовий робочий процес Mockito

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 — обʼєкт, який тестують, — також зазвичай створюється тут)

        // Act: дія

        // Assert: перевірка результату
        // Assertions нікуди не зникають — Mockito їх не замінює
        assertThat(true).isTrue();
    }
}

Mockito цього скелета не змінює. Він лише додає до Arrange потрібні заглушки й інколи — додаткові перевірки в Assert.

2. Робочий процес: mockstubbingact → assertions → verify

У новачків найчастіше збивається не Mockito, а порядок дій: stubbing роблять після виклику методу, який тестують, verify пишуть без змістовних assertions, а половину важливого ховають у спільний @BeforeEach. У результаті тест читається як квест: «Знайдіть, де тут узагалі сценарій». Давайте зберемо правильний порядок в одну зрозумілу схему.

Майже будь-який unit‑тест із Mockito можна розкласти так:

flowchart TD
    A[Підготовка: створити mock] --> B[Підготовка: налаштувати stubbing]
    B --> C[Дія: викликати метод SUT]
    C --> D[Перевірка: перевірити результат]
    D --> E[Перевірка: 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: підміняємо залежність, за побічний ефект відповідає саме вона
        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: підміна, на якій рахуватимемо виклики
        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, яка дає вердикт, і шлюз до збереження даних, який фіксує новий статус. Ми спеціально не підключаємо 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;
    }
}

Тепер — успішний сценарій: модерація 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, тестовий метод перестає бути самодостатнім: читаєте його й не розумієте, чому залежність повернула саме це.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ