1. «Зелений» тест і хибна впевненість
Найпідступніша пастка Mockito в тому, що зробити тест зеленим дуже легко. Пара рядків when(...).thenReturn(...), один assertThat(...) — і вже здається, ніби ви володар якості. Проблема в тому, що Mockito настільки слухняний, що іноді ви тестуєте не код ContentHub, а свою здатність написати сценарій для маріонеток. І тест при цьому лишається зеленим навіть тоді, коли в продакшн-коді все зламалося.
Най«чесніший» приклад марного тесту виглядає так: ви мокаєте сам тестований клас і перевіряєте, що mock повернув те, що ви в нього наперед поклали. Це не тест, а «вітаю, я успішно записав число 5 у змінну».
Нижче — спеціально іграшковий анти-приклад із нейтральною назвою класу. Тут важливий сам запах антипатерну; точна сигнатура цього шматка коду тут не має значення.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class GreenButUselessTest {
@Test
void mocks_the_class_under_test_and_proves_nothing() {
// Антипатерн: мокаємо сам клас, який маємо тестувати
ReviewDecisionService service = mock(ReviewDecisionService.class);
// Наперед «вкладаємо» відповідь у mock — це не перевірка бізнес-логіки
when(service.submitForReview(42L)).thenReturn("OK");
// Перевіряємо, що mock повернув те, що ми самі йому сказали повернути
assertThat(service.submitForReview(42L)).isEqualTo("OK");
}
}
Тест зелений. Тільки от він доводить рівно одне: Mockito вміє повертати наперед задані значення. Якщо завтра реальний сервіс почне повертати "FAIL", тест цього не помітить — бо він узагалі не бачив реальний код.
Щоб відчути, чи тест корисний, варто подумки ставити собі одне неприємне запитання: «Якщо я зламаю продакшн-код, цей тест почервоніє?». Якщо відповідь «ні» або «не впевнений», то ваш зелений тест — це зелена лампочка на панелі, яка під’єднана не до двигуна, а до батарейки в брелоку.
У світі Mockito хибна впевненість частіше ховається не в таких карикатурних прикладах, а в більш «культурних» формах: коли ви замокали все навколо, написали verify на десяток внутрішніх викликів, і тест починає перевіряти сценарій взаємодій, а не поведінку. Саме в таких «культурних» формах і живе основна небезпека: сценарій виглядає солідно, а поведінка досі не перевірена.
2. over-mocking: змокали світ — втратили сенс
Over-mocking — це ситуація, коли моків і stubbing стає так багато, що в тесті майже не лишається реальної поведінки. Виходить маленький театр: ви наперед прописали, хто що скаже, хто куди піде, хто кому помахає рукою — і наприкінці вистави всі щасливі, бо сценарій виконано. Тільки от реальне життя, тобто продакшн, ваш сценарій не читало.
У ContentHub сильна спокуса «змокати все» виникає навколо сервісів-оркестраторів. Наприклад, ArticleWorkflowService може смикати ModerationClient, SlugService, політику переходів, репозиторій і відправник повідомлень. І новачкові здається: «Ого, там багато залежностей, значить, треба все змокати, інакше тест “не unit”». Підсумок — тест на 80 рядків, у якому 70 рядків займає when(...).
Ось характерний запах over-mocking: ви бачите в тесті stubbing для простих даних — заголовка, body, category, — stubbing для часу, stubbing для поточного користувача, stubbing для генерації slug і ще десяток налаштувань «про всяк випадок», бо «раптом це знадобиться».
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class OverMockingSmellTest {
@Test
void too_many_mocks_make_test_a_script() {
// Підозріло: навіть дані статті зроблені mockами замість реального об’єкта
ArticleData data = mock(ArticleData.class);
when(data.getTitle()).thenReturn("Mockito 101");
when(data.getBody()).thenReturn("text");
// Мок зовнішнього клієнта — часто виправданий, але разом із моками «даних»
// це швидко перетворюється на сценарний театр
ModerationClient moderation = mock(ModerationClient.class);
when(moderation.moderate(anyString())).thenReturn(ModerationVerdict.OK);
}
}
Так, тут ще не весь тест — і в цьому іронія. Ми показали лише верхівку айсберга: уже видно mockи «даних» (що зазвичай не потрібно) і «будь-який аргумент підійде» (що зазвичай небезпечно). У великому тесті до цього додасться десяток verify, і ви отримаєте зелений сценарій, який легко переживе реальний злам бізнес-логіки.
Як зазвичай лікується over-mocking? Не магією, а поверненням сенсу. Ви згадуєте карту test doubles з першої лекції й починаєте з найпростішого запитання: «А можна цю частину залишити реальною?». Прості value-об’єкти та DTO майже завжди варто створювати справжніми. Маленьку залежність іноді краще замінити fake, ніж мокати її й через stubbing імітувати їй життя.
Порівняйте: замість мокування простих даних можна зібрати реальний об’єкт — і тест одразу стає чеснішим і коротшим.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LessMockingExampleTest {
@Test
void real_data_object_is_shorter_than_mocked_getters() {
// Прозоро: ми одразу бачимо реальні дані статті
ArticleData data = new ArticleData("Mockito 101", "text");
// Перевіряємо реальну поведінку реального об’єкта, без сценарію для геттерів
assertThat(data.getTitle()).isEqualTo("Mockito 101");
assertThat(data.getBody()).isEqualTo("text");
}
}
Це дуже простий приклад, але він добре показує принцип: mock — не за замовчуванням. Якщо об’єкт — це просто «пакет даних», створіть його реально. Mockito потрібен там, де реальний об’єкт незручний, повільний, нестабільний або де важливий сам факт зовнішнього виклику. Інакше у вас виходить просто сценарний театр.
Для швидкого орієнтування корисна коротка таблиця — не закон, а підказка на випадок, коли тест починає перетворюватися на сценарій:
| Що ви мокаєте | Як це зазвичай виглядає в тесті | Що це найчастіше означає |
|---|---|---|
| DTO / value objects | багато when(getX()).thenReturn(...) | ви імітуєте дані замість того, щоб використовувати дані |
| «Усе підряд» | 6–10 моків на один тест | об’єкт під тестом занадто складний або ви тестуєте не те |
| Внутрішні дрібниці | verify на «кожен крок» | overspecification (див. наступний розділ) |
3. overspecification: тест чіпляється за реалізацію
Overspecification — це коли тест занадто конкретний не за змістом, а за реалізацією. Ви фіксуєте, який саме метод якої залежності було викликано, скільки разів, у якому порядку, з якими проміжними значеннями — хоча бізнес-правило від цього не залежить. У результаті тести стають крихкими: будь-який рефакторинг, що зберігає поведінку, ламає половину тестів, і команда починає ненавидіти тести (а тести відповідають взаємністю).
Частий сценарій overspecification у ContentHub: ви хочете перевірити, що публікація статті надсилає сповіщення. Це справді зовнішній ефект — і verify тут доречний. Але потім рука тягнеться перевірити ще й те, що сервіс смикнув policy.canPublish, потім repo.findById, потім repo.save, потім notificationSender.send..., а ще й те, що «жодні інші методи не викликалися». І тест перетворюється на охоронця, який перевіряє не факт, що людина прийшла на роботу, а кількість кроків від метро до офісу.
Ось приклад «надто сценарного» тесту через InOrder. Він виглядає солідно, але часто доводить не те, що потрібно:
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import static org.mockito.Mockito.*;
class OverSpecifiedOrderTest {
@Test
void checks_call_order_even_if_order_is_not_part_of_behavior() {
// Моки залежностей — тут ок, ми тестуємо взаємодії
ArticleRepository repo = mock(ArticleRepository.class);
PublicationNotificationSender sender = mock(PublicationNotificationSender.class);
// Об’єкт під тестом використовує repo/sender
ArticlePublisher publisher = new ArticlePublisher(repo, sender);
publisher.publish(42L, "java-mockito");
// Антипатерн: фіксуємо порядок, хоча він може бути деталлю реалізації
InOrder order = inOrder(repo, sender);
order.verify(repo).save(any());
order.verify(sender).sendPublished(eq(42L), anyString());
}
}
Іноді порядок справді важливий. Наприклад, якщо ви зобов’язані спочатку зберегти стан, а потім надіслати сповіщення, тому що сповіщення має посилатися на збережені дані. Але в більшості бізнес-сервісів порядок внутрішніх викликів — це деталь реалізації. Сьогодні ви зберегли дані «перед відправленням», завтра додали буферизацію — і поведінка залишилася тією самою, але тест уже впав, бо «не в тому порядку помахали руками».
Більш здоровий варіант — перевіряти те, що справді є контрактом поведінки: що збереження відбулося і що сповіщення було надіслано. Причому часто навіть без згадки про порядок.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class LessBrittleInteractionTest {
@Test
void verifies_meaningful_effects_without_order_drama() {
// Моки меж: репозиторій і надсилання сповіщень — зовнішні ефекти
ArticleRepository repo = mock(ArticleRepository.class);
PublicationNotificationSender sender = mock(PublicationNotificationSender.class);
// Запускаємо дію
new ArticlePublisher(repo, sender).publish(42L, "java-mockito");
// Перевіряємо лише значущі ефекти, не розписуючи внутрішній «танець»
verify(repo).save(any());
verify(sender).sendPublished(eq(42L), anyString());
}
}
Ще одне популярне джерело overspecification — бажання «довести, що більше нічого не відбувалося» через verifyNoMoreInteractions(...). На словах це звучить як «чистота», а на ділі часто перетворюється на тест, який ламається при додаванні нешкідливого логування, метрики або додаткової перевірки. Таке вимагання іноді доречне на особливо чутливих межах — наприклад, «за помилки модерації ми точно не повинні надсилати сповіщення», — але як правило для всіх тестів це швидкий спосіб виростити крихку тестову систему.
4. Широкі matchers і stubbing «на все»
У попередній лекції про matchers ми вже говорили: anyString() і друзі — інструмент зручний, але небезпечний. Зараз ця небезпека проявляється в найнеприємнішому вигляді: тест лишається зеленим, навіть коли аргументи неправильні. Тобто ви перевіряєте факт виклику, але не перевіряєте сенс виклику. А в ContentHub сенс аргументу часто важливіший за сам факт.
Уявіть: ArticleWorkflowService надсилає текст статті в ModerationClient. Якщо сервіс помилково надішле порожній рядок, модерація формально «викликалася», але бізнес-сенс зруйновано. І якщо в тесті у вас стоїть when(moderation.moderate(anyString())).thenReturn(OK), ви самі дозволили сервісу надсилати що завгодно — хоч текст, хоч слово "котик", хоч порожнечу.
Дуже типовий «м’який» баг: тест пише anyString() «тимчасово», щоб не розбиратися, як саме збирається payload, а потім це «тимчасово» стає вічним. І далі будь-який дефект у складанні payload проходить повз.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class TooWideMatchersTest {
@Test
void anyString_stubbing_can_hide_wrong_payload() {
// Підготовка: mock зовнішнього клієнта
ModerationClient moderation = mock(ModerationClient.class);
// Небезпечно: дозволяємо «будь-який текст», тому неправильний payload не виявиться
when(moderation.moderate(anyString())).thenReturn(ModerationVerdict.OK);
// Навіть порожній рядок проходить — тест не захищає бізнес-сенс
moderation.moderate("");
// І перевірка надто м’яка: «хоч щось викликали» — і гаразд
verify(moderation).moderate(anyString());
}
}
Що робити замість цього? Якщо важливий точний рядок — використовуйте eq(...). Якщо рядок збирається всередині сервісу й точне співпадіння незручне, беріть ArgumentCaptor і перевіряйте смислові ознаки: що рядок не порожній, що він містить заголовок, що містить body, що не перевищує ліміт і так далі. Тобто перевіряйте те, що справді захищає поведінку.
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CaptorForMeaningTest {
@Test
void captures_payload_and_checks_meaning_not_exact_format() {
// Мок межі: нам важливо, що саме надіслали в модерацію
ModerationClient moderation = mock(ModerationClient.class);
// Виклик (у реальному тесті це робив би сервіс під тестом)
moderation.moderate("""
Title
Body
""");
// Захоплюємо фактичний аргумент виклику, щоб перевірити сенс
ArgumentCaptor<String> textCaptor = ArgumentCaptor.forClass(String.class);
verify(moderation).moderate(textCaptor.capture());
// Мінімально достатня перевірка: заголовок точно має бути присутній
assertThat(textCaptor.getValue()).contains("Title");
}
}
Важливо відчувати баланс. Надто жорстка перевірка формату теж може стати крихкою — наприклад, якщо зміниться роздільник рядків. Надто м’яка перевірка (anyString()) перетворює тест на пропускний пункт без охорони. Сенс у тому, щоб обирати «мінімально достатню строгість»: рівно настільки суворий тест, щоб дефект не проскочив, і не суворіше.
5. Мокування DTO та entity
Є особлива форма over-mocking, що виглядає «невинно»: мокування об’єктів із даними. Наприклад, ви мокаєте ArticleData, CreateArticleRequest, Article, Category — і починаєте stubbing-ом розкладати поля: when(getTitle()).thenReturn(...), when(getBody()).thenReturn(...) і так далі.
Чому це погано саме в тестах ContentHub? Бо так ви втрачаєте дві речі: читабельність і чесність. Читабельність губиться, бо замість «ось дані статті» ви бачите список when. Чесність — бо mock за замовчуванням повертає null / 0 / false, і ви легко можете не помітити, що частину даних забули підготувати. Тест стане випадково зеленим або випадково червоним, але точно не передбачуваним.
Порівняйте два підходи.
Поганий (мокаємо дані, пишемо сценарій для геттерів):
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class MockingDataIsNoiseTest {
@Test
void mocked_data_object_creates_setup_noise() {
// Антипатерн: data — просто контейнер значень, але ми робимо його mockом
ArticleData data = mock(ArticleData.class);
// У результаті тест «шумить» налаштуваннями замість того, щоб показувати дані
when(data.getTitle()).thenReturn("Mockito");
when(data.getBody()).thenReturn("Basics");
// далі тестований код читає data.getTitle()/getBody()
}
}
Хороший (реальний об’єкт даних, значення видно одразу):
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RealDataObjectTest {
@Test
void real_data_object_is_clear_and_honest() {
// Чесно: створюємо реальні дані без моків і stubbing
ArticleData data = new ArticleData("Mockito", "Basics");
// Дані видно очима, а не сховано в when(...).thenReturn(...)
assertThat(data.getTitle()).isEqualTo("Mockito");
assertThat(data.getBody()).isEqualTo("Basics");
}
}
У реальному unit-тесті ви, звісно, будете тестувати не ArticleData, а сервіс, який використовує ArticleData. Але принцип той самий: дані краще залишати даними, а не перетворювати їх на набір stubів. Іронічно, але один із найкращих «м’яких» способів захистити команду від мокування даних — використовувати незмінні структури (наприклад, record) і не тягнути в проєкт звичку «мокати все». Але й без record ця дисципліна працює: якщо об’єкт не робить I/O і не містить складної логіки, зазвичай його простіше створити, ніж мокати.
6. verify-голізм: спектакль замість перевірки
Перевірки взаємодій — дуже потужний інструмент, і тому ними легко зловживати. У новачка часто з’являється відчуття, що verify — це «справжня перевірка», а assertThat(result) — щось другорядне. На практиці зазвичай навпаки: перевірка результату або стану частіше дає більш стійку гарантію поведінки, а verify потрібний точково — коли зовнішній ефект і є ваша мета.
У ContentHub хороший приклад доречного verify — надсилання сповіщення про публікацію. Якщо ваш сервіс публікує статтю і зобов’язаний смикнути PublicationNotificationSender, то verify(sender).sendPublished(...) — це прямий доказ важливого ефекту. Аналогічно, якщо ArticleWorkflowService зобов’язаний смикнути ModerationClient під час надсилання на review, факт виклику модерації теж часто є частиною поведінки.
Поганий приклад verify — перевіряти внутрішні кроки алгоритму, які не є контрактом. Припустімо, SlugService всередині робить trim(), toLowerCase(), replaceAll(). Якщо ви почнете перевіряти, скільки разів викликано replaceAll або який regex використовувався — через spy і partial mock, — ви приб’єте тест до реалізації цвяхами. І будь-який нормальний рефакторинг стане «регресією тестів», хоча бізнес-результат лишиться тим самим.
Дуже допомагає психологічна техніка: уявіть, що завтра ви зміните реалізацію, але не поведінку. Чи повинен тест упасти? Якщо так — чудово. Якщо ні — значить, ви перевіряєте зайве. verify потрібний тоді, коли зміна взаємодії і є зміною поведінки — наприклад, сповіщення перестало надсилатися.
Ще один важливий момент: негативні перевірки (never(), times(0)) — корисна річ, але не як декоративний елемент. Вони сильні саме там, де «не робити» — частина правила. Наприклад: «якщо модерація повернула BLOCK, не надсилати сповіщення про публікацію». Тоді verify(sender, never()).sendPublished(...) буде осмисленим. Але якщо ви пишете never() просто тому, що «хочеться покрити все», тест стає штучним і крихким.
Як тримати Mockito-тести корисними
Якщо все сказане вище зібрати в одну практичну думку, вийде дуже немодне, але робоче правило: Mockito — це не мета і не стиль життя, а інструмент точкової ізоляції. Починайте з реальних об’єктів і додавайте мокування лише там, де без нього тест стає дорогим, недетермінованим або залежним від зовнішнього світу. І коли ви вже використовуєте mock, ставте собі запитання: «Я перевіряю результат? Чи я перевіряю сценарій викликів? Якщо сценарій — він справді важливий?».
Зручно тримати в голові маленьку «карту вибору» — не як релігію, а як нагадування, що mock не є варіантом за замовчуванням:
flowchart TD
A[Є залежність у класі під тестом] --> B{Чи можна залишити реальний об’єкт?}
B -->|Так| C[Залишаємо реальний об’єкт]
B -->|Ні| D{Чи можна зробити простий fake?}
D -->|Так| E["Використовуємо fake (in-memory, передбачуваний)"]
D -->|Ні| F{Потрібна лише наперед відома відповідь?}
F -->|Так| G[Stub: stubbing без verify]
F -->|Ні| H[Mock + verify лише значущий зовнішній ефект]
Ця схема особливо добре працює саме в нашому контексті ContentHub, де є і «технічні межі» — наприклад, зовнішня модерація та надсилання сповіщень, — і багато простої доменної інформації: статті, статуси, категорії, — яку взагалі не хочеться перетворювати на mockи. Чим більше реальних даних у тесті, тим простіше читати сценарій. Чим менше verify на внутрішніх кроках, тим дешевше коштує рефакторинг.
7. Типові помилки
Перед тим як закрити тему, корисно проговорити кілька помилок, які найчастіше призводять до тих самих «зелених тестів без сенсу». Вони звучать банально, але саме з таких дрібниць зазвичай і складається крихкий набір тестів.
Помилка № 1: мокати сам тестований клас.
Це найпряміший шлях отримати тест, який перевіряє Mockito замість бізнес-логіки. Якщо ви пишете mock(ArticleWorkflowService.class) і потім перевіряєте, що mock повернув "OK", ви не тестуєте ContentHub. Ви перевіряєте, що Mockito не забув ваші налаштування.
Помилка № 2: мокати об’єкти-дані та stubbing-ом налаштовувати геттери.
Коли ви мокаєте DTO/Entity/Value Object і починаєте писати when(getTitle()).thenReturn(...), тест перестає бути читабельним сценарієм. Крім того, ви легко пропускаєте поля: mock поверне null, і ви вже не впевнені, тест червоний через реальний дефект чи через забуті дані.
Помилка № 3: використовувати any() майже всюди й радіти, що «тест не крихкий».
Так, тест стає «не крихким», але часто з дуже простої причини: він узагалі нічого не перевіряє. Якщо ви пишете when(moderation.moderate(anyString())).thenReturn(OK), то сервіс може надіслати порожній рядок, неправильний формат або взагалі не той текст — і тест усе одно залишиться зеленим.
Помилка № 4: перевіряти порядок викликів без реальної потреби.
InOrder може бути корисним, але лише коли порядок — частина поведінки. Якщо порядок — деталь реалізації, тест стає прив’язаним до маршруту виконання. Будь-який рефакторинг ламає його, навіть якщо бізнес-результат лишився тим самим.
Помилка № 5: перетворювати verifyNoMoreInteractions(...) на обов’язковий ритуал.
У рідкісних випадках це доречно — наприклад, коли потрібно довести: «точно не надсилати сповіщення при помилці». Але якщо ви додаєте verifyNoMoreInteractions «заради дисципліни», то починаєте карати код за будь-яке нешкідливе поліпшення: зайву перевірку, метрику, лог. У підсумку люди починають обходити тести, а не поліпшувати код.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ