1. Роль spy поруч із mock()
Якщо mock() — це «актор за сценарієм», який робить лише те, що ми йому прописали, то spy — це «справжній співробітник», за яким ми поставили камеру спостереження, а іноді ще й підсовуємо йому шпаргалку в кишеню.
Іноді в тесті хочеться залишити об’єкт справжнім, тому що його поведінка проста, корисна й не хочеться переписувати її вручну. Водночас ви можете захотіти або переконатися, що певні методи справді викликалися, або точково підмінити невеликий фрагмент поведінки, щоб тест став детермінованим.
На папері це звучить чудово: беремо реальний об’єкт, підглядаємо за ним і за потреби трохи втручаємося. На практиці саме тут і починаються типові граблі. spy майже завжди стоїть на межі між «мені треба швидко» і «мені треба надійно». Він не заборонений і не «соромний», але потребує дисципліни, тому що за замовчуванням виконує справжній код. А справжній код має нахабну звичку ходити у файлову систему, генерувати випадкові значення, залежати від часу, падати з винятками — словом, робити все те, від чого unit-тести зазвичай і намагаються втекти.
Вам потрібно навчитися одночасно бачити дві речі. Перша: де spy — нормальний маленький інструмент, який робить тест зрозумілішим. Друга: де spy — червоний прапорець, що об’єкт занадто великий і погано відокремлений від своїх залежностей.
2. spy у Mockito
Коли ви створюєте spy, ви не отримуєте «порожню заглушку», як у mock(). Ви берете справжній екземпляр і просите Mockito загорнути його. Ззовні це Mockito-об’єкт: його можна verify()-ити й підміняти, але всередині він залишається справжнім. Методи за замовчуванням справді виконуються, стан справді змінюється, а винятки справді летять вам в обличчя, якщо ви зробили щось не так.
Зручно тримати в голові коротке порівняння:
| Характеристика | mock() | spy() |
|---|---|---|
| Є справжній об’єкт усередині | ні | так |
| Методи за замовчуванням виконуються | ні (повертаються значення за замовчуванням) | так |
| Можна verify() | так | так |
| Можна stubbing | так | так, але з нюансами |
| Ризик випадкового I/O/рандому | низький | високий |
| Добре для великих залежностей | так (якщо правильно) | частіше ні |
Найважливіший висновок: spy — це не «ще один вид mock». Це інструмент, який зберігає реальну поведінку. Тому першим питанням завжди має бути не «як зробити spy», а «чи справді мені потрібна реальна поведінка цього об’єкта в цьому тесті?».
Нижче — дуже простий приклад, де spy виглядає доречно: ми хочемо, щоб список справді змінювався, і водночас хочемо перевірити, що додавання справді відбувалося.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class EventListSpyTest {
@Test
void add_shouldActuallyModifyList() {
// Spy обгортає реальний ArrayList: методи справді виконуються
List<String> events = spy(new ArrayList<>());
// Справжній виклик: список справді зміниться
events.add("PUBLISHED");
// Але ми все одно можемо перевіряти взаємодії, як у mock
verify(events).add("PUBLISHED");
// І перевіряти реальний стан, бо всередині — справжній список
assertThat(events).containsExactly("PUBLISHED");
}
}
Тут spy логічний: ArrayList — невеликий, детермінований і зрозумілий об’єкт. Ми не боїмося, що він раптово піде в мережу або почне залежати від часового поясу. Ми просто хочемо «поставити камеру» й переконатися, що важливий виклик був.
Якщо ж ви використовуєте spy для об’єкта, який усередині робить щось серйозне, то буквально кажете тесту: «живи небезпечно». Іноді це виправдано, але частіше — ні.
3. spy у ContentHub
У нашому наскрізному проєкті ContentHub є різні типи компонентів. Є «чисті» елементи з локальною логікою — наприклад, нормалізація slug або перевірка простих правил. Є порти й адаптери: надсилання сповіщень, робота з файлами, зовнішній moderation client. І є сервіси-оркестратори, які зв’язують усе разом.
На практиці spy найчастіше з’являється у двох ситуаціях: коли у вас є маленький об’єкт, чию поведінку хочеться залишити реальною, і коли всередині об’єкта є невелика ділянка недетермінізму — випадковий суфікс, генерація ID, поточний час, — яку хочеться прибити цвяхами.
Уявімо дуже спрощений SlugService. У ContentHub slug має бути зручним і читабельним, а унікальність часто досягається додаванням невеликого суфікса. І саме суфікс — це «проблема для тесту», тому що він випадковий.
import java.util.UUID;
class SlugService {
String slugify(String title) {
// Детермінована частина: нормалізація заголовка
String base = title.toLowerCase().replace(" ", "-");
// Недетермінована частина: випадковий суфікс
return base + "-" + randomSuffix();
}
String randomSuffix() {
// Справжній UUID робить тести нестабільними, якщо не зафіксувати поведінку
return UUID.randomUUID().toString().substring(0, 8);
}
}
Якщо ми хочемо протестувати (або просто демонстраційно показати) поведінку slugify(), нам не хочеться, щоб тест залежав від випадкового UUID. Ось тут spy технічно може допомогти: ми залишаємо реальний slugify(), але підміняємо randomSuffix().
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class SlugServiceSpyTest {
@Test
void slugify_canBeMadeDeterministicWithSpy() {
// Spy залишає реальну реалізацію slugify(...)
SlugService slugService = spy(new SlugService());
// Для spy stubbing безпечніше робити через doReturn(...).when(...)
// щоб не викликати реальний метод під час налаштування
doReturn("deadbeef").when(slugService).randomSuffix();
// Перевіряємо детермінований результат
assertThat(slugService.slugify("Hello World"))
.isEqualTo("hello-world-deadbeef");
}
}
Цей приклад корисний саме як демонстрація ідеї partial mock. Але він же показує й важливу річ: якщо в проєкті регулярно доводиться так стабілізувати випадковість, значить, недетермінізм захований усередині об’єкта. Часто здоровіше винести генерацію суфікса в окрему залежність і залишити SlugService повністю детермінованим. Тоді й тест простіший, і spy не перетворюється на звичку.
Так само підозріло виглядає spy на великих сервісах. Якщо ви в ContentHub шпіонуєте, наприклад, за ArticleWorkflowService і починаєте підміняти половину його внутрішніх методів, тест буквально каже: «я не розумію меж відповідальності цього класу». І це добрий момент не радіти, що тест «зелений», а зупинитися й запитати: «а чому мені взагалі довелося лізти всередину?».
4. Partial mock
Partial mock — це не окрема магічна сутність. У Mockito це майже завжди означає одне: ви зробили spy(realObject), а потім застаббили один або кілька методів. У результаті отримуєте об’єкт, який поводиться «майже як справжній», але в одній точці ми кажемо: «ні, друже, ось тут ти поводишся так, як зручно тесту».
Це трохи схоже на побутову ситуацію: ви перевіряєте роботу кавомашини в офісі, але не хочете, щоб вона реально гріла воду, тому що це довго і може зламати ваш тестовий стенд. Ви кажете: «усе інше роби чесно, але нагрів вважаємо миттєвим». Звучить кумедно — і рівно так само виглядає partial mock, якщо використовувати його бездумно.
Візьмімо маленький клас, де один метод спирається на інший.
class ArticleDraft {
String title() {
// Реальний метод, який ми НЕ підміняємо в тесті
return "Java Mockito";
}
String slug() {
// Метод, який у тесті буде підмінено (partial mock)
return title().toLowerCase().replace(" ", "-");
}
}
А тепер partial mock у тесті: title() залишається справжнім, а slug() ми підміняємо.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class ArticleDraftSpyTest {
@Test
void slug_canBeOverridden_inPartialMock() {
// Беремо реальний об’єкт і обгортаємо його в spy
ArticleDraft draft = spy(new ArticleDraft());
// Підміняємо лише один метод — виходить partial mock
doReturn("ready-slug").when(draft).slug();
// slug береться зі stub
assertThat(draft.slug()).isEqualTo("ready-slug");
// title залишається справжнім, бо ми його не підміняли
assertThat(draft.title()).isEqualTo("Java Mockito");
}
}
З погляду техніки — усе коректно. З погляду сенсу — тут є велике питання: а навіщо нам узагалі підміняти slug() на об’єкті, який сам і відповідає за slug()? Дуже часто partial mock на «нашому» об’єкті означає, що ми намагаємося тестувати занадто багато одразу або не можемо стабілізувати поведінку нормальним способом — наприклад, через залежності.
Тому partial mock допустимий, але має залишатися рідкісним і зрозумілим винятком, а не основним способом «робити тести зеленими».
5. Пастка stubbing для spy
З mock() ви звикаєте до моделі: «поки я не викликав метод у Act, нічого не відбувається». З spy() це не зовсім так. Найвідоміша й найболючіша пастка — коли ви пишете when(spy.someMethod()).thenReturn(...). Mockito, щоб «зловити» виклик, іноді справді викликає метод — а отже, справжній код виконується просто в момент налаштування.
На маленьких об’єктах це може бути просто несподівано. На великих — уже катастрофа: ви випадково йдете у файлову систему, робите мережевий запит або отримуєте виняток ще до того, як тест узагалі почав перевіряти щось по суті.
Класичний приклад на списку:
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.mockito.Mockito.*;
class SpyStubbingPitfallTest {
@Test
void when_onSpy_mayCallRealMethodDuringArrange() {
List<String> list = spy(new ArrayList<>());
// ПОГАНО: реальний list.get(0) буде викликано просто тут і впаде:
// when(list.get(0)).thenReturn("first"); // IndexOutOfBoundsException
}
}
Правильний шлях для spy — сімейство doReturn(...).when(spy).method() (а також doThrow, doAnswer, doNothing). Ці форми не вимагають «викликати метод просто зараз», тому вони безпечніші й передбачуваніші.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class SpyStubbingCorrectTest {
@Test
void doReturn_onSpy_isSafe() {
// Spy на реальному списку
List<String> list = spy(new ArrayList<>());
// Безпечний stubbing для spy: метод не буде викликано під час Arrange
doReturn("first").when(list).get(0);
// Тут повернеться значення зі stub, а не реальний list.get(0)
assertThat(list.get(0)).isEqualTo("first");
}
}
Зверніть увагу, наскільки це змінює мислення: у світі spy налаштування — це не просто «опис сценарію», а ще й захист від випадкового виконання коду під час налаштування. Тому в реальних проєктах часто діє негласне правило: якщо ви пишете stubbing для spy, майже завжди використовуйте doReturn/doThrow, навіть якщо when(...).thenReturn(...) «нібито спрацювало» один раз на вашому ноутбуці.
6. spy як сигнал архітектури
Є хороша інженерна звичка: не лише користуватися інструментами, а й читати симптоми, які вони показують. spy — один із найсимптомніших інструментів у тестуванні. Він часто з’являється не тому, що ви «любите spies», а тому, що об’єкт важко тестувати інакше.
Нижче — таблиця, яка допомагає швидко зрозуміти, що саме може означати spy у тесті. Це не закон, а діагностична шпаргалка.
| Що ви бачите в тесті | Що це часто означає | Чому це ризик |
|---|---|---|
| spy() на великому сервісі | сервіс робить занадто багато | тест починає лізти у внутрішності |
| doReturn() на 3–4 методах одного об’єкта | клас змішує різні обов’язки | тест стає «сценарієм викликів», а не перевіркою поведінки |
| spy() потрібен, щоб прибити випадковість/час | недетермінізм захований усередині | тести виходять крихкими й неявними |
| spy() викликає реальні побічні ефекти | залежність не ізольована | тест починає залежати від середовища |
| verify(spy).privateHelper() (або аналог) | ви перевіряєте реалізацію, а не поведінку | будь-який рефакторинг ламає тести без зміни сенсу |
Ідея дуже проста: spy — це нормально, коли він обгортає маленький, чистий і передбачуваний об’єкт. Але якщо spy стає «костилем» для великого компонента, це часто означає, що об’єкт спроєктовано не дуже вдало для тестування. Не тому, що хтось поганий розробник, а тому, що в звичайній розробці так буває: спочатку пишемо фічу, потім починаємо тестувати — і раптом з’ясовується, що частини відповідальності краще було б розділити.
Важливо не впадати в крайність «spy — зло». Це приблизно як сказати: «скотч — зло». Скотч іноді рятує, коли відвалилася ручка у валізи. Але якщо вся валіза тримається на скотчі, проблема не в скотчі — проблема у валізі.
7. Вибір між spy і mock()
Коли з’являється спокуса застосувати spy, корисно прогнати в голові маленьке дерево рішень. Воно допомагає не перетворювати spy на автоматичну звичку, але й не демонізувати його там, де він справді робить тест простішим.
flowchart TD
A["Чи потрібна реальна логіка цього об’єкта в тесті?"] -->|Ні| B["Використовуйте mock/stub/fake"]
A -->|Так| C["Об’єкт маленький і детермінований?"]
C -->|Так| D["Spy допустимий (обережно)"]
C -->|Ні| E["Це сигнал: спробуйте винести залежність або спростити об’єкт"]
D --> F["Потрібно підмінити метод?"]
F -->|Так| G["Використовуйте doReturn/doThrow для spy"]
F -->|Ні| H["Використовуйте spy лише для verify, а не для вистави"]
Якщо сказати по-людськи, то виходить так. Якщо вам не потрібна реальна логіка — не беріть spy, беріть mock або fake. Якщо реальна логіка потрібна, але об’єкт великий і з побічними ефектами, spy стає небезпечним. Якщо об’єкт маленький і чистий, spy цілком може бути найчитабельнішим рішенням.
Якщо потрібно підміняти метод у spy, краще йти через doReturn/doThrow, інакше ви ризикуєте виконати реальний код ще в Arrange. І нарешті, якщо ви використовуєте spy, щоб перевіряти внутрішні виклики, то тест починає захищати не поведінку, а реалізацію — а це майже завжди робить набір тестів крихким.
8. Типові помилки під час роботи зі spy і partial mocks
spy дуже підступний тим, що він «виглядає» як звичайний Mockito-об’єкт, але «поводиться» як реальний. Тому помилки з ним часто не вкладаються в голові: ви робите те саме, що з mock(), а тест або падає несподівано, або проходить, але доводить якісь дивні речі.
Помилка №1: вважати spy «безпечним mock» і забути про реальне виконання коду.
Це найчастіша історія. Ви обгортаєте об’єкт у spy, очікуючи, що він буде «порожнім» і слухняним, а потім раптом отримуєте реальний виняток, реальний доступ до файла або зависання. Причина проста: у spy реальні методи продовжують виконуватися за замовчуванням. Якщо об’єкт робить хоч щось недетерміноване, тест перетворюється на лотерею.
Помилка №2: використовувати when(spy.method()).thenReturn(...) і випадково викликати реальний метод під час налаштування.
На mock() це безпечно майже завжди, а на spy — одна з найнеприємніших пасток. Метод може бути викликаний у Arrange, а ви будете довго думати, чому тест падає «до Act». Для spy майже завжди простіше й безпечніше перейти на doReturn(...).when(spy).method() або doThrow(...), щоб налаштування не запускало реальний код.
Помилка №3: шпіонити за великим сервісом і підміняти половину його методів.
Коли тест перетворюється на «частково справжній, частково замоканий» монстр, ви втрачаєте сенс. Такий тест складно читати, складно підтримувати, і він погано пояснює, що саме захищає. Зазвичай це означає, що сервісу тісно в одній відповідальності: він і правила містить, і інфраструктуру чіпає, і стан збирає, і побічні ефекти надсилає. У такій ситуації spy стає не рішенням, а димом від пожежі.
Помилка №4: перевіряти через spy внутрішні helper-виклики замість поведінки.
verify(spy).someInternalMethod() виглядає дуже «точно», але часто перевіряє те, що не зобов’язане бути стабільним. Сьогодні ви викликаєте helper-метод, завтра вбудовуєте логіку напряму — поведінка та сама, а тест падає. Це і є надмірне специфікування, тільки в одязі spy. Якщо тесту важливий результат або зовнішній ефект — перевіряйте їх, а не те, як саме ви до них дійшли.
Помилка №5: використовувати partial mock на об’єкті, який є «тестованим», і радіти зеленому тесту.
Якщо ви підмінили метод на об’єкті, який за задумом має бути перевірюваною логікою, ви ризикуєте отримати тест, який доводить налаштування Mockito, а не бізнес-правило. Partial mock допустимий як виняток — наприклад, коли ви стабілізуєте маленьку ділянку недетермінізму, — але якщо підміняєте «головну» логіку класу, тест перестає бути тестом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ