JavaRush /Курси /Spring Test /Spring TestContext і кешування контексту

Spring TestContext і кешування контексту

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

1. Ціна Spring‑тестів проти unit‑тестів

Ми вже відділили звичайні unit‑тести від повноцінних контекстних і slice-тестів. Тепер важливо побачити ціну цього вибору: щойно в тесті з’являється Spring-контекст, разом із ним приходять час запуску, пам’ять і правила його повторного використання.

Якщо до цього моменту ви писали тести без Spring, то ваш мозок звик до дуже простої моделі: JUnit створив об’єкт тесту, викликав метод, ви отримали результат. У Spring‑світі між «JUnit запустив тест» і «виконався ваш @Test метод» з’являється товстий шар інфраструктури: потрібно підняти контейнер, створити біни, зв’язати залежності та підготувати середовище. Це якраз той випадок, коли «зручність» і «магія» оплачуються часом і пам’яттю.

У unit‑тесті JUnit робить приблизно ось що: створює екземпляр тестового класу, викликає @Test метод — і все. Spring у таких тестах взагалі не бере участі, тому вони зазвичай швидкі, детерміновані й чудово підходять для перевірки бізнес‑правил.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class PublicationPolicyTest {

    @Test
    void allowsDraftToReview() {
        // Створюємо об’єкт із чистою бізнес-логікою без Spring
        PublicationPolicy policy = new PublicationPolicy();

        // Дія й перевірка: перевіряємо правило переходу статусів
        assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW)).isTrue();
    }
}

У Spring‑тесті додається підготовчий етап: Spring має зібрати ApplicationContext. Навіть якщо сам тест порожній, ціну вже сплачено — контекст створюється до виконання ваших методів.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ContentHubApplicationSmokeTest {

    @Test
    void contextLoads() {
        // Це smoke-тест інфраструктури:
        // ми перевіряємо сам факт запуску ApplicationContext, а не бізнес-логіку.
        //
        // Якщо цей тест зелений, це означає, що контекст піднявся.
        // Але це не означає, що ваш бізнес-сценарій працює.
    }
}

Важливо вловити думку: Spring‑тест може бути порожнім за кодом, але важким за фактом. І далі вся розмова буде про те, як зробити так, щоб ця вага була усвідомленою та виправданою, а не «на автоматі».

2. Spring TestContext Framework: міст між JUnit і Spring

Поки ви пишете unit‑тести, JUnit — головний диригент. Щойно ви додаєте Spring‑анотації, з’являється «перекладач» між світом JUnit і світом Spring. Цей перекладач — Spring TestContext Framework. Його завдання звучить нудно, але по суті він робить дуже важливу річ: дає змогу JUnit‑тесту жити поруч із Spring‑контейнером так, ніби це нормально, звично і зовсім не магія (хоча магія там, звісно, є).

Якщо говорити просто, TestContext Framework відповідає на запитання на кшталт: яку конфігурацію використовувати, як створити ApplicationContext, чи можна повторно використати вже створений контекст, як увести біни в тест, коли виконувати підготовку й очищення середовища. Тобто він буквально стає «операційною системою» Spring‑тестів.

Зараз не потрібно вчити внутрішні класи Spring напам’ять. Достатньо зрозуміти грубу схему: JUnit передає керування Spring‑шару, той піднімає або повторно використовує ApplicationContext, і тільки потім виконується ваш @Test. Цього вже досить, щоб зрозуміти, звідки береться ціна Spring‑тесту.

Корисно уявити ланцюжок запуску у вигляді схеми — вона не ідеальна за внутрішніми деталями, але добре показує, хто кому передає керування:

flowchart TD
    JUnit["JUnit 6 запускає тестовий клас"] --> Ext["SpringExtension (розширення JUnit 6)"]
    Ext --> TCM["TestContextManager"]
    TCM --> Merged["MergedContextConfiguration (що саме треба підняти)"]
    Merged --> Loader["ContextLoader (як підняти контекст)"]
    Loader --> Ctx["ApplicationContext (контейнер Spring)"]
    Ctx --> Test["Виконання методів @Test"]

І ось тут є важлива деталь: у більшості Spring Boot тестів ви не пишете @ExtendWith(SpringExtension.class) вручну, бо Boot‑анотації роблять це за вас. Але розуміти, що розширення існує, корисно: ви краще бачите межу «тут закінчився JUnit» і «тут почався Spring».

Наприклад, у чистому Spring‑тесті можна явно написати:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
class PlainSpringExtensionTest {

    @Test
    void testRunsWithSpringExtension() {
        // Важливо: лише @ExtendWith вмикає механіку TestContext,
        // але сама по собі ще не гарантує підняття ApplicationContext.
        // Контекст підніметься лише тоді, коли тест попросить про це анотаціями або конфігурацією.
    }
}

А Boot‑тести зазвичай стартують одразу «з батарейками», і це зручно. Просто важливо пам’ятати: зручність — це не «безкоштовно», а «під капотом хтось уже все зробив за вас».

3. Запуск Spring‑тесту і створення ApplicationContext

Коли ви вперше ставите @SpringBootTest або будь-який інший Spring‑test slice, ви ніби кажете: «Гаразд, JUnit, передай слово Spring — нехай він підготує сцену». І Spring починає будувати сцену: сканує конфігурації, піднімає авто-конфігурації, створює біни, може відкривати з’єднання з БД (залежить від типу тесту), налаштовує конвертери, валідатори й купу інших речей, про які ви поки навіть не підозрюєте (і це нормально).

Важливий момент для початківця: під час першого запуску тесту з цією конфігурацією Spring піднімає потрібний ApplicationContext ще до виконання методів тесту. Далі методи цього класу зазвичай працюють уже всередині готового контексту, а якщо така сама конфігурація трапиться в іншому класі, контекст може повернутися з кешу. Інакше кажучи, ціну «підняття світу» сплачують не на кожен @Test метод заново, а на створення конкретного варіанта контексту.

Відчуйте різницю в масштабі на прикладі: у unit‑тесті ви створюєте один об’єкт. У Spring‑тесті ви потенційно створюєте сотні об’єктів‑бінів, навіть якщо потім у тесті використовуєте один-єдиний.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ArticlePublicationFlowIntegrationTest {

    @Test
    void runsInSpringContext() {
        // Тест може бути порожнім за кодом, але не порожнім за вартістю:
        // контекст уже піднято, біни вже створено, середовище вже підготовлено.
    }
}

Ця «передоплата» і пояснює, чому в Spring‑тестах швидкість набору залежить не лише від кількості тестів, а й від кількості різних конфігурацій контексту, які потрібно підняти.

І тут ми приходимо до рятівного круга. Ні, не до «мокати все». А до дуже прагматичної речі: context caching.

4. Context caching: сенс і принцип роботи

Тобто кешується не «клас як такий», а вже зібраний контекст для конкретної конфігурації. Якби Spring щоразу піднімав новий ApplicationContext для кожного тестового класу, будь-який реальний проєкт помер би від тестів, а розробники перейшли б у професію садівника — там менше болю. Тому Spring TestContext Framework уміє кешувати контексти та повторно використовувати їх.

Ідея проста: якщо два тестові класи просять однаковий контекст, Spring піднімає його один раз, кладе в кеш і далі роздає повторно. Для JUnit це виглядає майже як «нічого не відбувається», а за фактом ви економите секунди, а іноді й хвилини на великому наборі тестів.

Схематично це можна уявити так:

sequenceDiagram
    participant T1 as TestClass A
    participant Cache as Cache контекстів
    participant Spring as Spring

    T1->>Cache: "Чи є контекст для такої конфігурації?"
    Cache-->>T1: "Ні"
    T1->>Spring: "Підніми ApplicationContext"
    Spring-->>Cache: "Ось готовий контекст"
    Cache-->>T1: "Тримай"

    Note over Cache: Наступний тестовий клас із такою самою конфігурацією

    participant T2 as TestClass B
    T2->>Cache: "Чи є контекст для такої конфігурації?"
    Cache-->>T2: "Так (потрапляння в кеш)"
    Cache-->>T2: "Тримай той самий контекст"

Ось чому «однакова конфігурація тестів» — це не естетика і не смаківщина. Це реальна швидкість.

Але є й зворотний бік: якщо ви створюєте багато майже однакових, але формально різних конфігурацій — кеш не допоможе. Ви отримаєте багато «cache miss» і купу піднятих контекстів. А потім ще й здивуєтеся, чому «у мене всього 40 тестів, а вони біжать як дипломна робота».

5. Відмінності конфігурацій і новий контекст

Найпрактичніше запитання цієї лекції звучить так: що саме робить контекст “іншим” для кешу? Бо якщо ми це розуміємо, ми можемо свідомо писати тести так, щоб кеш працював на нас, а не проти нас.

У Spring TestContext Framework є поняття «об’єднаної конфігурації» тесту (часто її називають merged configuration). Туди потрапляє все, що впливає на те, який саме ApplicationContext має бути створений. На людському рівні можна запам’ятати таке правило: якщо ви змінили щось, що може вплинути на набір бінів або їхні налаштування, — найімовірніше, це новий контекст.

Нижче будуть з’являтися профілі, property overrides і підміни бінів. Поки не потрібно розбирати їхню механіку окремо: тут вони важливі лише як приклади того, чому два тести можуть просити або один і той самий, або вже інший контекст. Для цієї лекції достатньо одного правила: змінюється набір бінів або значущі налаштування — конфігурація перестає бути тією самою.

Нижче — невелика таблиця-шпаргалка. Вона не претендує на 100% формальну точність внутрішніх ключів кешу, але чудово допомагає ухвалювати інженерні рішення.

Зміна в тесті Що найчастіше станеться Чому це важливо
Ви використовуєте іншу «головну» анотацію тесту (@SpringBootTest vs slice) Буде новий контекст Це різні режими завантаження і різні набори авто-конфігурацій
У тесті імпортується додаткова конфігурація Часто буде новий контекст Змінюється набір бінів / зв’язування
В одному класі одні моки бінів, в іншому — інші Часто буде новий контекст Контекст уже «інший», бо частину бінів замінено
Змінюєте активний профіль Новий контекст Профілі впливають на те, які біни активні та які властивості використовуються
Перевизначаєте властивості (property overrides) Новий контекст Налаштування змінюють поведінку і іноді зв’язування
Нічого не змінюєте: та сама анотація, та сама конфігурація Контекст повторно використовується Це той самий щасливий шлях (cache hit)

Тепер важливе застереження: кешування — це оптимізація, але не магія безсмертя. Контекст — річ важка, і в кешу є обмеження за розміром, щоб не з’їсти всю пам’ять JVM і не перетворити тести на «OutOfMemoryError як стиль життя». Тому якщо ви створите надто багато різних контекстів, старі почнуть витіснятися, і ви почнете платити за їх створення повторно.

І ось це вже дуже схоже на реальність: тести стали «то швидкі, то повільні», бо порядок запуску змінився, кеш витіснив контекст, і частина набору почала заново прогріватися. У такі моменти особливо хочеться звинуватити ретроградний Меркурій, але зазвичай винна просто «хаотична конфігурація тестів».

6. Зайва анотація і сповільнення тестів

Дуже легко потрапити в пастку: «Я поставлю @SpringBootTest, тоді точно все буде як у реальному застосунку, а отже надійно». Це звучить логічно… доки ви не згадаєте правило мінімально достатнього тесту й ціну контексту.

Візьмімо максимально простий приклад із ContentHub: SlugService. Це чиста логіка перетворення рядка на slug. Для неї Spring не потрібен. Якщо ви напишете тест так:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class SlugServiceSpringBootTest {

    @Autowired
    private SlugService slugService;

    @Test
    void convertsTitleToSlug() {
        // Ми перевіряємо одну функцію, але платимо за підняття всього контексту.
        assertThat(slugService.toSlug("Spring Boot Testing"))
                .isEqualTo("spring-boot-testing");
    }
}

Він, найімовірніше, буде зеленим. Але він «дорогий» не тому, що ви написали багато коду. Він дорогий тому, що ви підняли контекст заради перевірки однієї функції. Це як викликати евакуатор, щоб перемістити велосипед на один метр: технічно задача вирішена, але оточуючі починають тихо хвилюватися.

Правильний варіант — звичайний unit‑тест:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class SlugServiceTest {

    @Test
    void convertsTitleToSlug() {
        // Тут ми тестуємо чисту логіку без контейнера:
        // швидше, простіше й без побічних ефектів від спільного контексту.
        SlugService slugService = new SlugService();

        assertThat(slugService.toSlug("Spring Boot Testing"))
                .isEqualTo("spring-boot-testing");
    }
}

Тут немає жодного ApplicationContext, жодного TestContext Framework, жодного кешу — бо нема чого кешувати. І це нормально. Кеш потрібен для Spring‑тестів, а не для того, щоб виправдовувати їхнє існування.

Тепер більш «підступний» приклад. Припустімо, ви пишете кілька тестових класів і в кожному додаєте @SpringBootTest, бо «так простіше уводити залежності». Навіть якщо кеш повторно використовує один і той самий контекст між ними, ви все одно:

1) піднімаєте великий контекст там, де часто можна було обійтися меншим рівнем тесту;
2) тримаєте в пам’яті важкий ApplicationContext заради дрібних перевірок;
3) ризикуєте випадково почати змінювати конфігурацію від класу до класу, і тоді вже отримаєте кілька контекстів.

Класична картина: проєкт росте, тестів стає більше, і десь непомітно з’являється «зоопарк контекстів», бо кожен тестовий клас просить «свою маленьку правочку» через анотації. Ззовні це виглядає як «кілька невинних рядків», а за фактом — як «ще один новий ApplicationContext, будь ласка».

7. Правила роботи з context caching

Коли ви чуєте слово «кеш», легко розслабитися: «О, отже, про ціну можна не думати». Але в тестах це працює навпаки: кеш допомагає лише тоді, коли ви дисципліновано робите конфігурації однаковими й повторюваними. Тому корисно тримати в голові кілька правил поведінки — не як релігію, а як інженерну гігієну.

Перше правило звучить майже нудно: якщо Spring не потрібен, не піднімайте Spring. Це не «економія на сірниках», а фундаментальна стратегія: unit‑рівень має залишатися швидким і масовим, а Spring‑рівень — точковим і усвідомленим. Кешування не має ставати виправданням для @SpringBootTest усюди.

Наступне правило: прагніть до того, щоб тести одного типу мали максимально однакову конфігурацію. На практиці це означає, що якщо ви пишете кілька тестів одного й того самого шару (наприклад, перевіряєте одну й ту саму межу застосунку), вам вигідно тримати спільний набір анотацій і спільну схему підключення залежностей. Тоді кеш «зловить» схожі випадки і спрацює.

Третє правило: будьте обережні з «маленькими налаштуваннями в одному тесті». Будь-яке точкове налаштування через анотації може зробити тестовий контекст унікальним. Іноді це потрібно — і тоді ви платите усвідомлено. Але іноді це просто звичка «підкрутити, щоб працювало», і тоді ви платите за хаос.

І нарешті, важливе правило, яке дивує новачків: раз контекст повторно використовується, отже, ваші тести спільно використовують один і той самий набір singleton-бінів. Якщо якийсь бін усередині тесту змінює свій стан (наприклад, ви вручну додали щось у кеш-колекцію або ваш фейковий адаптер зберігає історію викликів), то наступний тест може отримати «брудний» стан. Зазвичай це призводить до flaky: тести падають лише в певному порядку. І в цей момент кешування здається прокляттям. Насправді прокляття — це «спільний змінний стан без дисципліни».

8. Типові помилки TestContext і кешу

У цій темі майже всі помилки виглядають однаково: ви написали «дві нешкідливі анотації», а тести почали запускатися так, ніби на калькуляторі з 90‑х. Це нормальна стадія дорослішання Spring‑розробника: спочатку ми радіємо, що Spring уміє все, а потім вчимося платити за це рівно стільки, скільки потрібно, — і не більше.

Помилка № 1: робити @SpringBootTest «за замовчуванням», навіть для локальної логіки.
Таке рішення часто маскується під аргумент «менше мороки, все само введеться». Але за фактом ви платите запуском і підтримкою повного контексту там, де мав жити простий unit‑тест. Гірше того, ви привчаєте себе шукати рішення через контейнер, а не через дизайн коду.

Помилка № 2: думати, що кешування робить Spring‑тест «майже безкоштовним».
Так, кешування пришвидшить повторні звернення до однакового контексту, але перше підняття все одно дороге. Плюс контекст займає пам’ять, а кеш не безмежний. Якщо контекстів багато, вони витісняються, і ви знову платите за створення. Кеш — це знижка, а не безкоштовна підписка.

Помилка № 3: плодити унікальні конфігурації тестів «по дрібницях».
Сьогодні ви додали невеликий імпорт, завтра — невелике підмінення, післязавтра — невелику властивість в одному тесті. У підсумку ви отримали пачку контекстів, які майже однакові за змістом, але різні для кешу. Діагностувати це неприємно: тести нібито схожі, а запускаються повільно.

Помилка № 4: випадково протікати станом між тестами через singleton-біни.
Через кешування контекст живе довше одного тестового методу. Якщо ви використовуєте stateful helper-біни або фейки, які накопичують дані, наступний тест може стартувати в неочікуваному стані. Зазвичай це проявляється як «тест проходить окремо, але падає в наборі». І це один із найбільш дратівливих видів проблем, бо він виглядає як «рандом».

Помилка № 5: намагатися лагодити все «скиданням світу» замість пошуку причини.
Коли тести починають впливати один на одного, новачок часто хоче «перезапускати контекст щоразу». Це справді може тимчасово прибрати симптом, але зазвичай робить набір тестів повільним і ховає архітектурну причину: спільний змінний стан, надто широку зону тестування або неакуратну конфігурацію. У тестуванні майже завжди вигідніше лікувати причину, ніж збільшувати ціну кожного запуску.

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