JavaRush /Курси /Spring Core /Тести без Spring і з контекстом

Тести без Spring і з контекстом

Spring Core
Рівень 24 , Лекція 0
Відкрита

1. Вибір: без контексту чи з ним

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

До цього моменту ContextFlow уже вміє більше, ніж звичайний набір POJO з парою new: у ньому є профілі, властивості, події, AOP і навіть маленький legacy XML-хвіст. Отже, тепер потрібно відділити дві різні перевірки: що є просто логікою класу, а що існує лише тому, що контейнер зібрав застосунок саме так.

Головна ідея сьогоднішньої лекції — не «unit-тести проти контекстних тестів» як релігійна війна, а просте інженерне правило: перед тим як писати тест, сформулюйте одне головне запитання, на яке він відповідає. Якщо запитання про логіку класу, контейнер вам не потрібен. Якщо запитання про збирання застосунку контейнером (зв’язування, профілі, властивості, події, legacy XML), без контексту тест втрачає сенс.

Щоб це стало зовсім відчутним, давайте зведемо різницю в маленьку таблицю — не як «шпаргалку на іспит», а як карту місцевості:

Що ви хочете перевірити Зазвичай підходить Чому
Алгоритм / правило / розрахунок (чиста логіка) Unit test без Spring Ви створюєте об’єкт через new, залежностям даєте прості підміни, і падіння тесту прямо вказує на код правила
Що клас викликає потрібного «сусіда» (делегування) Unit test без Spring + stub Контейнер не додає цінності: ви перевіряєте взаємодію об’єктів, а не збирання графа
Що контекст узагалі стартує і збирається Контекстний тест Це перевірка конфігурації та зв’язування — без контексту ви не дізнаєтеся, що застосунок насправді не стартує
Що в профілі test підставилися потрібні біни Контекстний тест Профілі — контейнерна механіка; вручну «зібрати нібито test» — не те саме
Що @EventListener реально спрацьовує Контекстний тест Слухачів реєструє контейнер; без контексту ви тестуєте не Spring-події, а звичайні виклики методів

Якщо хочете зовсім коротке «дерево рішень», воно виглядає так:

flowchart TD
    A["Я пишу тест"] --> B["Моє запитання про логіку класу?"]
    B -->|Так| C["Unit-тест (new + stub)"]
    B -->|Ні| D["Моє запитання про збирання або поведінку Spring?"]
    D -->|Так| E["Контекстний тест (ApplicationContext)"]
    D -->|Ні| F["Ймовірно, запитання сформульовано нечітко — уточніть мету тесту"]

У ContextFlow це особливо важливо, тому що ми вже встигли набрати «смачних» контейнерних фіч: profiles, properties, conversion, events, AOP і legacy XML. Усе це легко зламати конфігом — і важко діагностувати вручну, якщо немає тестів.

2. Unit-тест без Spring

Unit-тест — це той самий момент, коли Spring можна ввічливо попросити постояти осторонь. Ви берете клас, створюєте його звичайним new, підставляєте залежності вручну й перевіряєте поведінку. Для новачка це ще й психологічно корисно: ви не ховаєтеся за контейнером, а бачите, які об’єкти насправді беруть участь у роботі. І так, це ідеально лягає на філософію Spring про POJO: «ваші сервіси — звичайні класи, Spring лише допомагає їх зібрати».

Щоб не говорити абстрактно, візьмемо крихітний фрагмент із нашого домену: сервіс, який надсилає сповіщення через порт NotificationSender. Нам не потрібно піднімати контекст, щоб перевірити, що сервіс справді делегує надсилання.

Спочатку інтерфейс (порт), який у нас у проєкті вже існує в domain.ports:

public interface NotificationSender {
    // Контракт порту: сервісу неважливо, як саме надсилати повідомлення — головне, щоб його було надіслано
    void send(String message);
}

Далі — stub (проста тестова підміна), який запам’ятовує останнє повідомлення:

public class StubNotificationSender implements NotificationSender {
    // У stub-і ми фіксуємо факт взаємодії (що саме надіслали),
    // а не намагаємося «реально надсилати» в SMS, пошту чи чергу.
    String lastMessage;

    @Override
    public void send(String message) {
        // Запам’ятовуємо аргумент — тест потім перевірить, що сервіс передав правильне значення
        this.lastMessage = message;
    }
}

Тепер сервіс, який ми хочемо протестувати:

public class NotificationDispatchService {
    // Залежність виражена через інтерфейс (порт) — саме це й робить unit-тест простим
    private final NotificationSender sender;

    public NotificationDispatchService(NotificationSender sender) {
        this.sender = sender;
    }

    public void dispatch(String message) {
        // Уся логіка тут — делегування: перевіряємо, що виклик справді йде в sender
        sender.send(message);
    }
}

І сам тест на JUnit Jupiter (без Spring, без контексту, без магії й без «а де тут анотації?»):

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class NotificationDispatchServiceTest {
  @Test void dispatchDelegatesToSender() {
    // Підготовка: підміна залежності
    var sender = new StubNotificationSender();
    // Підготовка: тестований сервіс — створюємо вручну, без контейнера
    var service = new NotificationDispatchService(sender);

    // Дія: виконуємо виклик
    service.dispatch("order-created");

    // Перевірка: повідомлення дійшло до залежності
    assertEquals("order-created", sender.lastMessage);
  }
}

У цьому тесті важливо те, що він відповідає рівно на одне запитання: «Сервіс справді надсилає повідомлення через залежність?». Якщо він упаде, причина буде або в цьому класі, або в stub-і — тобто в місці, яке у вас перед очима. Жодних «контекст не піднявся, тому що не знайшов PropertySource», коли ви взагалі хотіли перевірити один метод.

Щоб показати, що unit-тести корисні не лише для делегування, візьмемо приклад трохи ближче до бізнес-логіки: знижку. Її можна і треба перевіряти без Spring, тому що знижка — це правило, а не контейнерна фіча.

import java.math.BigDecimal;

public interface DiscountPolicy {
    // Політика знижки: на вході сума, на виході сума після застосування правила
    BigDecimal apply(BigDecimal original);
}
import java.math.BigDecimal;

public class NoDiscountPolicy implements DiscountPolicy {
    @Override
    public BigDecimal apply(BigDecimal original) {
        // «Нульова знижка» — повертаємо суму як є
        return original;
    }
}
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class NoDiscountPolicyTest {
  @Test void keepsOriginalAmount() {
    // Важливо: BigDecimal краще створювати з рядка, щоб не зловити сюрпризи бінарної точності
    var policy = new NoDiscountPolicy();
    assertEquals(new BigDecimal("100.00"), policy.apply(new BigDecimal("100.00")));
  }
}

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

3. Stub-об’єкти

Коли люди вперше чують про «підміни» в тестах, вони часто одразу думають про mocking-фреймворки й починають мріяти про Mockito, як про суперсилу: «зараз я тут усе замокую, і світ стане передбачуваним». Іноді — так. Але на нашому рівні (і в межах курсу) нам найчастіше вистачає stub-а: маленького класу, який реалізує інтерфейс і поводиться максимально просто. Це як дублер актора: він не зобов’язаний грати геніально, його завдання — безпечно пройти сцену.

Stub допомагає unit-тесту залишатися маленьким і чесним. Замість «справжнього» відправника SMS або запису у файл ми використовуємо об’єкт, який лише запам’ятовує, що до нього прийшли. Так ми перевіряємо поведінку тестованого класу, а не побічні ефекти інфраструктури.

Наприклад, у ContextFlow аудит — це окремий порт AuditWriter. У unit-тестах ми зазвичай не хочемо писати в консоль або у файл (шумно, повільно, іноді ламається на CI). Ми хочемо зрозуміти: «аудит узагалі було викликано?». Stub для цього може виглядати так:

import java.util.ArrayList;
import java.util.List;

public class StubAuditWriter implements AuditWriter {
    // Зберігаємо всі повідомлення, щоб тест міг перевірити:
    // 1) що аудит було викликано
    // 2) скільки разів його було викликано
    // 3) які саме повідомлення туди пішли
    final List<String> messages = new ArrayList<>();

    @Override
    public void write(String message) {
        // Фіксуємо факт виклику без I/O, без файлів, без консолі
        messages.add(message);
    }
}

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

У наших лекціях і в проєкті ContextFlow ми заздалегідь зробили важливу річ: винесли взаємодію із зовнішнім світом у порти (domain.ports). Це означає, що для unit-тестів підміни писати дуже легко: реалізуйте інтерфейс — і все.

4. Контекстний тест у Spring

Контекстний тест — це той випадок, коли ви тестуєте не «клас у вакуумі», а роботу Spring як збирача вашого застосунку. І тут дуже важливо не обманювати себе: якщо ви створюєте всі залежності через new і вручну «ніби збираєте застосунок», то ви тестуєте… своє ручне збирання, а не Spring. Це іноді корисно (наприклад, для unit-тестів), але це не відповідає на запитання «а контейнер збере це так само?».

Контекстні тести потрібні там, де ваша поведінка виникає лише завдяки контейнеру. У ContextFlow таких місць багато: активні профілі, завантаження властивостей, перетворення, слухачі через @EventListener, legacy XML bridge, post-processors, proxy/AOP. Усе це — не «просто Java-код». Це контейнерна механіка, і якщо ви хочете впевненості в ній, то піднімаєте контекст.

Піднімати контекст вручну корисно один раз: так краще видно, що саме робить контейнер. Але це свідомо проміжний варіант. Щойно таких тестів стає більше, ініціалізацію й закриття контексту хочеться віддати Spring Test, а не носити в кожному методі вручну.

У цій лекції ми не використовуємо інтеграцію Spring Test (її візьмемо в наступній лекції), а просто покажемо мінімальний сенс: контекст можна підняти прямо в тесті вручну, щоб побачити різницю.

Конфігурація для тесту:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class TestConfig {
    // Найпростіший бін, щоб тест міг перевірити: контекст піднявся і повертає біни
    @Bean
    String appName() {
        return "contextflow-test";
    }
}

І тест, який перевіряє саме контейнерну реєстрацію:

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.junit.jupiter.api.Assertions.assertEquals;

class ContextPresenceTest {
  @Test void readsBeanFromContext() {
    // Важливо: контекст — ресурс, його потрібно закривати.
    // try-with-resources гарантує закриття навіть у разі падіння assert-а.
    try (var ctx = new AnnotationConfigApplicationContext(TestConfig.class)) {
      // Перевіряємо, що бін справді зареєстрований контейнером і витягується з контексту
      assertEquals("contextflow-test", ctx.getBean(String.class));
    }
  }
}

Це не «корисний бізнес-тест» (і не має ним бути). Його сенс в іншому: показати, що запитання тесту — про контейнер. Якщо @Bean зникне, якщо конфігураційний клас не зареєструється, якщо контекст не стартує — тест упаде. І це саме те, що ми хочемо зловити, коли говоримо про контекстні тести.

У ContextFlow контекстні тести зазвичай відповідають на запитання на кшталт: «у профілі test ми справді використовуємо DeterministicOrderIdGenerator?», «чи налаштувався MessageSource?», «чи legacy XML-фрагмент справді підхопився?», «слухачі подій реально зареєструвалися?». Ці запитання неможливо чесно перевірити одним new.

5. Межа в ContextFlow

Тепер давайте приземлимо все на наш проєкт, тому що абстрактна філософія тестів без прив’язки до коду звучить красиво, але рятує від багів приблизно як надихаюча цитата на чашці. У ContextFlow ми спеціально розділяли шари: домен і application-сервіси мають бути відносно «POJO-сумісними», а контейнерна магія живе в config/support та інфраструктурі. Це не випадковість — так тестування стає простішим.

Нижче — практична карта, де найчастіше лежить межа. Це не закон природи, а орієнтир, щоб ви не зависали над кожним класом по 30 хвилин, обираючи «unit чи context».

Частина ContextFlow Приклад механіки/класу Що розумно перевіряти Тип тесту
domain.model інваріанти OrderItem, статуси що не створюються «безглузді» об’єкти, коректні переходи unit
application.service OrderPricingService розрахунки, правила, реакції на вхідні дані unit (stub ports)
application.service OrderPlacementService оркестрація як послідовність кроків (частково) unit або сценарний з контекстом (залежить від мети)
infrastructure.reporting TextReportFormatter форматування рядка, структура звіту unit
support.conversion String -> NotificationChannel converter реакція на email, sms, неправильні значення unit (на converter)
config.profiles @Profile-гілки що в test/demo обираються потрібні реалізації контекстний
domain.events + listeners OrderCreatedEvent що подія публікується і слухачі спрацьовують контекстний
config.legacybridge @ImportResource що XML-фрагмент справді підхопився і дає біни контекстний
support.aop аспект і proxy що сервіси стали proxy і advice застосувався контекстний (якщо взагалі тестуємо)

Зверніть увагу на важливу думку: один і той самий клас іноді можна тестувати по-різному. Наприклад, OrderPlacementService як «послідовність дій» можна покрити unit-тестом зі stub-ами (швидко, локально). Але якщо ваша мета — перевірити потік подій і спрацьовування слухачів, тоді ви вже робите контекстний сценарний тест, тому що інакше ви перевіряєте не Spring-події, а «ручний виклик методів».

6. Один тест — одне запитання

Найчастіша біда новачка в тестуванні Spring-проєкту — тест, який намагається перевірити «взагалі все». Він піднімає контекст, створює замовлення, перевіряє знижку, перевіряє, що слухач написав аудит, перевіряє, що звіт зберігся у файл… і потім падає через те, що десь змінили ключ властивості. У результаті ви сидите й думаєте: «Так, зачекайте, я тестував знижку чи читання властивостей?».

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

Практичне правило, яке справді працює: спробуйте подумки закінчити фразу «Цей тест потрібен, щоб переконатися, що…». Якщо ви не можете закінчити її одним реченням — значить, у тесту кілька цілей, і його варто розділити. У ContextFlow це виглядатиме приблизно так: окремий unit-тест перевіряє, що знижка застосовується правильно; окремий контекстний smoke test перевіряє, що DiscountPolicy узагалі резолвиться й не конфліктує з іншими реалізаціями; окремий event-flow test перевіряє, що після OrderCreatedEvent спрацював listener.

Так, тестів стане більше. Зате кожен буде простіший, швидший і чесніший. І, що приємно, у вас не буде відчуття, що «Spring тестується заклинаннями».

7. Типові помилки під час вибору між unit і context тестом

Помилка № 1: піднімати Spring-контекст «для солідності» там, де потрібен звичайний new.
Зазвичай це виглядає так: ви хочете перевірити 5 рядків логіки, але запускаєте контекст, тягнете конфігурацію, і тест падає через зв’язування. Це не робить тест «інтеграційнішим», це робить його менш зрозумілим. Якщо ваше запитання не про Spring, Spring у тесті — зайвий шум.

Помилка № 2: намагатися перевірити контейнерну механіку через ручне збирання об’єктів.
Наприклад, ви «перевіряєте профілі», але в тесті самі створюєте new DeterministicOrderIdGenerator і радієте. Це перевірка того, що ви вмієте писати new, а не того, що @Profile("test") справді працює. Контейнерні механіки тестуються контекстом — інакше ви просто обходите предмет перевірки.

Помилка № 3: змішувати в одному тесті перевірку бізнес-логіки й перевірку зв’язування.
Спочатку здається, що це зручно: «один тест на сценарій». На практиці ви отримуєте тест, який ламається від будь-якої правки, а причина падіння неочевидна. Краще розділити: unit-тести ловлять помилки логіки, а контекстні — помилки збирання застосунку.

Помилка № 4: не використовувати stub там, де реальна залежність створює шумні побічні ефекти.
У unit-тестах особливо небезпечні реальні записи у файли, консоль і все, що залежить від середовища. Навіть якщо тест проходить у вас, він може почати блимати в CI або на іншій машині. Stub дає контроль: ви фіксуєте факт взаємодії, а не сподіваєтеся на «ну, здається, файл створюється».

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

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