JavaRush /Курси /Spring Test /spy і partial mock: к...

spy і partial mock: коли вони допомагають

Spring Test
Рівень 4 , Лекція 3
Відкрита

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 допустимий як виняток — наприклад, коли ви стабілізуєте маленьку ділянку недетермінізму, — але якщо підміняєте «головну» логіку класу, тест перестає бути тестом.

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