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. Робочий процес: mock → stubbing → act → 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, тестовий метод перестає бути самодостатнім: читаєте його й не розумієте, чому залежність повернула саме це.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ