JavaRush /Курси /Spring Test /Unit-тести сервісу-оркестратора без Spring

Unit-тести сервісу-оркестратора без Spring

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

1. ArticleWorkflowService: оркестрація без випадковостей

Якщо PublicationPolicy — це майже підручник із «чистої логіки», то ArticleWorkflowService — це вже життя. Сервіс-оркестратор знає трохи більше: він не просто відповідає «можна/не можна», а збирає об’єкт, ставить часові мітки, підставляє автора, запускає генератор slug і відправляє результат у репозиторій. І якщо хоч одна з цих речей береться «з повітря» — наприклад, через Instant.now() — тест перетворюється на ворожіння на кавовій гущі: то зелений, то — «ну… майже зелений».

Найважливіша думка цієї лекції дуже приземлена: unit-тест сервісу-оркестратора має контролювати все, що може бути випадковим. У ContentHub такими джерелами випадковості майже завжди стають поточний час, поточний користувач і службові обчислення на кшталт slug. Це не магія і не філософія — просто інженерна гігієна.

Давайте спочатку зафіксуємо, що саме зазвичай робить ArticleWorkflowService. У реальному проєкті методів буде більше, але нам достатньо зрозумілої схеми: створити чернетку, відправити на ревʼю, опублікувати, відхилити.

flowchart LR
    S[ArticleWorkflowService] --> P[Політика публікації]
    S --> R[Репозиторій статей]
    S --> U[Постачальник поточного користувача]
    S --> C[Годинник]
    S --> SL[Сервіс slug]

Сервіс стоїть у центрі: він поєднує «локальні правила» (policy), «обчислення» (slug), «контекст» (user/time) і «межу зберігання» (repository). І це чудово тестується unit-тестом — якщо не намагатися одночасно підняти Spring, БД, HTTP та інші радощі життя.

2. Видимі залежності: constructor injection

Коли початківець пише сервіс, перед ним зазвичай два шляхи. Перший — швидкий і небезпечний: всередині методу викликати Instant.now(), «якось» дізнатися користувача, там же створити new SlugService(), а потім дивуватися, чому тести нагадують боротьбу з гідрою. Другий шлях — нудний, правильний і зрештою приємний: усі залежності приходять у конструктор, а тест сам вирішує, що саме вважати «зараз», хто користувач і який slug вийде.

Нам не потрібно переписувати весь production-код у цій лекції, але корисно побачити мініфрагмент, який робить тести можливими. Суть у тому, що час береться з Clock, користувач — з CurrentUserProvider, slug — з SlugService, а збереження йде через ArticleRepository. Політику ми зазвичай залишаємо реальною (її вже перевірили в другій лекції), бо це дешевше і чесніше, ніж мок.

import java.time.Clock;
import java.time.Instant;

public class ArticleWorkflowService {

    // Важливо: репозиторій — зовнішня межа (у unit-тесті зазвичай мок)
    private final ArticleRepository repository;
    // Policy — «чисте правило», його часто залишають реальним навіть у unit-тестах
    private final PublicationPolicy policy;
    // Slug — обчислення, яке зручно ізолювати моками/заглушками
    private final SlugService slugService;
    // Поточний користувач — залежність, а не глобальний стан
    private final CurrentUserProvider currentUserProvider;
    // Час — через Clock, щоб тест міг «заморозити» момент
    private final Clock clock;

    public ArticleWorkflowService(ArticleRepository repository,
                                  PublicationPolicy policy,
                                  SlugService slugService,
                                  CurrentUserProvider currentUserProvider,
                                  Clock clock) {
        this.repository = repository;
        this.policy = policy;
        this.slugService = slugService;
        this.currentUserProvider = currentUserProvider;
        this.clock = clock;
    }

    // Нижче в лекції будемо тестувати методи
}

Зверніть увагу: тут немає ні Instant.now() без параметрів, ні SecurityContextHolder (який ми взагалі не чіпаємо в unit-layer), ні «створимо залежність усередині методу, бо так швидше». Для тестів це величезна перемога.

Щоб закріпити думку, корисно побачити мінітаблицю: що саме ми контролюємо в unit-тесті й як.

Залежність Чому вона заважає тесту, якщо залишити «як є» Чим замінюємо в unit-тесті
Clock час кожного запуску буде різним Clock.fixed(...)
CurrentUserProvider хто саме користувач залежить від оточення лямбда () -> "alice" або мок
SlugService ми не хочемо в цьому тесті перевіряти алгоритм slug мок SlugService або проста заглушка
ArticleRepository реальна БД — це вже інший рівень тестів Mockito mock + ArgumentCaptor
PublicationPolicy це чисте правило, уже тестували окремо зазвичай використовуємо реальний об’єкт

З цією картою в голові можна спокійно писати тести на оркестрацію, не перетворюючи їх на «інтеграційні тести, замасковані під unit».

3. Керований час: Clock.fixed(...)

Час — найпопулярніший тихий шкідник unit-тесту. Він завжди під рукою, його легко взяти, і майже завжди саме він робить тест або недетермінованим, або занадто крихким. Погана новина: Instant.now() без Clock у бізнес-методі майже гарантовано призводить до перевірок у дусі «ну, приблизно дорівнює». Хороша новина: Java давно дає нормальний спосіб зробити час керованим — Clock.

У тестах нам важливі дві речі. По-перше, мати фіксований Instant, щоб можна було порівнювати значення напряму. По-друге, використовувати зрозумілу часову зону, зазвичай UTC, щоб не ловити сюрпризи на кшталт «вчора о 23:00 за Токіо» раптом стало «сьогодні за UTC». У ContentHub нам простіше жити з Instant і фіксувати ZoneOffset.UTC.

Ось як виглядає «заморожений час» у тесті:

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

// Фіксуємо «поточний час» для тесту: він більше не залежить від реального годинника
Clock clock = Clock.fixed(
        Instant.parse("2026-03-18T10:15:30Z"),
        ZoneOffset.UTC
);

І ось як production-код має цим користуватися: не Instant.now(), а Instant.now(clock).

import java.time.Instant;

// Важливо: беремо час через Clock, щоб тест міг керувати «зараз»
Instant now = Instant.now(clock); // детерміновано для тесту

У цей момент тест перестає бути «про удачу» і стає доказом поведінки. Якщо сервіс під час створення чернетки має поставити createdAt, ми можемо стверджувати точне значення. Якщо під час відправлення на ревʼю має зʼявитися submittedAt, знову — точне значення. І тести читаються як сценарії, а не як спроба домовитися з реальністю.

4. Поточний користувач: CurrentUserProvider

Поточний користувач у backend-проєкті — це майже така сама «погода за вікном», як і поточний час. У production він може жити в security-контексті, токені, сесії — де завгодно. У unit-тесті нас це абсолютно не цікавить. Ми не тестуємо безпеку, не тестуємо Spring Security і не тестуємо фільтри — ми перевіряємо, що сервіс коректно використовує ім’я користувача як вхідний сигнал.

Тому на unit-рівні ми робимо простий інтерфейс, який повертає ім’я користувача. Він не зобов’язаний бути великим і розумним — чим простіше, тим краще. І так, це якраз той випадок, коли лямбда — не зло, а ліки від зайвої церемонії.

@FunctionalInterface
// Абстракція потрібна, щоб у unit-тесті підставити користувача без security-контексту
public interface CurrentUserProvider {
    String currentUsername();
}

У тесті це перетворюється на один рядок:

CurrentUserProvider currentUser = () -> "alice";

Далі сервіс може використовувати це під час створення статті або під час перевірки права на дію. У цій лекції ми зосередимося на першому: під час створення чернетки автор має бути встановлений передбачувано.

І важлива методична думка: ми не повинні в unit-тесті «доводити» всі security-обмеження. Нам достатньо, що ArticleWorkflowService бере автора із залежності, а не з глобального стану на кшталт System.getProperty("user.name"). Останнє іноді трапляється в навчальних проєктах, і так — це приблизно як зберігати пароль у коментарі «щоб не забути».

5. Slug як обчислення: використовуємо SlugService

Slug — зручна технічна річ: зрозумілий людям ідентифікатор статті, який приємно бачити в URL. Але в unit-тесті оркестратора нас не цікавить, як саме він будується: чи прибирає діакритику, що робить з емодзі тощо. Slug — окремий unit-target зі своїм власним контрактом. Тут ми перевіряємо інше: що ArticleWorkflowService справді викликає SlugService і кладе результат у статтю.

Практично це означає, що SlugService у тесті можна спокійно замокати й сказати: «На будь-який вхід повертай spring-testing». Тоді тест зосередиться на оркестрації: автор, час, статус, виклик репозиторію.

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;

// Мокуємо SlugService, бо тут ми тестуємо оркестрацію, а не алгоритм slug
SlugService slugService = mock(SlugService.class);
given(slugService.toSlug("Spring Testing")).willReturn("spring-testing");

Якщо ви не любите моки там, де можна обійтися простим об’єктом, це нормально. Але з SlugService найчастіше зручно саме так: один метод, одна підміна, і тест не розповзається. Головне — пам’ятати правило з уроку про Mockito: мок потрібен, щоб ізолювати залежність, а не щоб «мокувати світ».

6. Репозиторій: Mockito + ArgumentCaptor

Репозиторій — ідеальний кандидат на мок на unit-рівні. Причина проста: справжній репозиторій майже завжди означає базу даних, транзакції, конфігурацію та інші речі, які ми свідомо не вмикаємо сьогодні. Але проблема з’являється одразу: якщо ми просто зробимо verify(repository).save(...), то доведемо лише факт виклику, а не якість даних.

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

Мінімальне налаштування ArgumentCaptor виглядає так:

import org.mockito.ArgumentCaptor;

// Captor потрібен, щоб перевірити, ЩО саме сервіс відправив у repository.save(...)
ArgumentCaptor<Article> captor = ArgumentCaptor.forClass(Article.class);

А перевірка виглядає приблизно так: «зберегли статтю, зловили аргумент, перевірили author, slug, createdAt».

import static org.mockito.Mockito.verify;

// Перехоплюємо аргумент виклику save(...), щоб перевірити заповнені поля сутності
verify(repository).save(captor.capture());
Article saved = captor.getValue();

Цей підхід особливо добрий, коли сервіс не просто прокидує параметри, а збирає об’єкт, доповнює його часовими мітками й статусом. Тобто саме наш випадок.

І ще один важливий момент: не треба перетворювати це на спектакль із десяти verify. Зазвичай достатньо одного verify(save) і кількох тверджень щодо вмісту. Якщо ви почнете перевіряти кожну дрібну взаємодію, тест стане крихким і ламатиметься від безневинного рефакторингу.

7. Unit-сценарії: чернетка та ревʼю

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

Тест createDraft: автор, slug і createdAt

Почнемо з підготовки залежностей. Тут найкраще видно, що саме робить тест детермінованим.

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

import static org.mockito.Mockito.mock;

Clock clock = Clock.fixed(Instant.parse("2026-03-18T10:15:30Z"), ZoneOffset.UTC);
CurrentUserProvider currentUser = () -> "alice";

ArticleRepository repository = mock(ArticleRepository.class);
SlugService slugService = mock(SlugService.class);

Тепер додамо policy і налаштуємо slug.

import static org.mockito.BDDMockito.given;

PublicationPolicy policy = new PublicationPolicy();
given(slugService.toSlug("Spring Testing")).willReturn("spring-testing");

І збираємо сервіс — constructor injection у всій красі.

ArticleWorkflowService service = new ArticleWorkflowService(
        repository, policy, slugService, currentUser, clock
);

Тепер сам тестовий сценарій. Ми викличемо createDraft(...), захопимо аргумент save(...) і перевіримо поля. Намагайтеся тримати набір перевірок рівно тим, що доводить сенс сценарію: автор, slug і час.

import org.junit.jupiter.api.Test; 
import org.mockito.ArgumentCaptor;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;

@Test
void createDraft_setsAuthorSlugAndCreatedAt() {
    // Arrange/Act: викликаємо бізнес-метод (усередині він збере Article і викличе repository.save)
    service.createDraft("Spring Testing", "short", "body", "java");

    // Assert: перевіряємо, що саме збереглося
    var captor = ArgumentCaptor.forClass(Article.class);
    verify(repository).save(captor.capture());

    Article saved = captor.getValue();
    assertThat(saved.getAuthorUsername()).isEqualTo("alice");
}

Продовжимо перевірки в тому ж стилі, але окремим маленьким блоком, щоб не робити один код-блок на пів екрана.

import java.time.Instant;

assertThat(saved.getSlug()).isEqualTo("spring-testing");
assertThat(saved.getStatus()).isEqualTo(ArticleStatus.DRAFT);
assertThat(saved.getCreatedAt()).isEqualTo(Instant.parse("2026-03-18T10:15:30Z"));

Зверніть увагу на важливу тонкість, яку багато хто пропускає: ми не перевіряємо тут, як саме побудувався slug. Ми перевіряємо, що сервіс узяв slug із SlugService і поклав його в статтю. Алгоритм slug — окремий unit-target. Саме так тести залишаються компактними й не дублюють одне одного.

Тест submitForReview: статус і submittedAt

Відправлення на ревʼю — це вже перехід статусу. Policy вирішує, чи можна переходити. WorkflowService вирішує, коли і що саме змінити в статті. Ми не будемо повторювати матрицю policy, але покажемо, що оркестратор правильно ставить час.

Створимо статтю в статусі DRAFT. У проєкті це може бути фабричний метод Article.draft(...) або builder — не принципово. Для прикладу використаємо фабрику.

import java.time.Instant;

Article article = Article.draft(
        "Spring Testing",
        "spring-testing",
        "alice",
        "java",
        Instant.parse("2026-03-18T10:00:00Z")
);

Змінимо час на момент відправлення, щоб точно бачити різницю між createdAt і submittedAt.

import java.time.Clock;
import java.time.ZoneOffset;

Clock submitClock = Clock.fixed(Instant.parse("2026-03-18T11:00:00Z"), ZoneOffset.UTC);

ArticleWorkflowService submitService = new ArticleWorkflowService(
        repository, policy, slugService, currentUser, submitClock
);

Тепер викликаємо submitForReview(...) і перевіряємо статус та часову мітку. Тут можна перевіряти або змінену статтю, або те, що відправилося в repository.save(...). В ідеалі не потрібні обидва способи одночасно; оберіть один спостережуваний ефект. Для оркестратора частіше зручно перевіряти саме те, що зберігається.

import static org.mockito.Mockito.verify;
import org.mockito.ArgumentCaptor;

submitService.submitForReview(article);

var captor = ArgumentCaptor.forClass(Article.class);
verify(repository).save(captor.capture());

Article saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);

І завершуємо перевіркою часу:

assertThat(saved.getSubmittedAt())
        .isEqualTo(Instant.parse("2026-03-18T11:00:00Z"));

Якщо у production-коді раптом стояло Instant.now() без Clock, тест або буде нестабільним, або ви почнете писати перевірки «не null» замість точного значення. А «не null» — слабкий доказ. Воно ловить лише «ми взагалі не забули заповнити поле», але не ловить «ми заповнили його неправильним часом».

8. Негативний кейс: заборонений перехід статусу

Негативні сценарії важливі, але в лекції про ArticleWorkflowService ми не хочемо перетворюватися на повтор лекції про PublicationPolicy. Тому візьмемо один показовий випадок: спробуємо опублікувати статтю не з IN_REVIEW, а з DRAFT. Policy має заборонити перехід, а сервіс — не має зберігати зміни.

Спочатку підготуємо статтю в неправильному статусі.

Article draft = Article.draft(
        "Bad Publish",
        "bad-publish",
        "alice",
        "java",
        Instant.parse("2026-03-18T10:00:00Z")
);

Тепер очікуємо виняток. Тип винятку у вашому проєкті може називатися інакше, але сенс один: «недопустимий перехід статусу».

import org.junit.jupiter.api.Test; 

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

@Test
void approveDraft_throwsAndDoesNotSave() {
    // Перевіряємо, що за забороненого переходу сервіс явно повідомляє про помилку
    assertThatThrownBy(() -> service.approve(draft))
            .isInstanceOf(InvalidStatusTransitionException.class);

    // І водночас нічого не зберігає в репозиторій
    verify(repository, never()).save(draft);
}

Тут є важливий методичний нюанс. Ми перевірили never(save(draft)), але якщо сервіс створює новий об’єкт або зберігає інше посилання, такий assert може промахнутися. У цьому разі надійніше перевіряти never(save(any(Article.class))), хоча це вже ширший контракт. Зазвичай розумний баланс такий: або сервіс за дизайном працює з тією самою сутністю — тоді перевірка з draft доречна, або ви просто перевіряєте, що save взагалі не викликався.

import static org.mockito.Mockito.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

verify(repository, never()).save(any(Article.class));

Головне — не піти в перевірку десятка взаємодій. Тут ми доводимо одне: за недопустимого переходу оркестратор не має тихо «зберегти щось». Це вже добрий рівень захисту від регресії.

9. Типові помилки під час unit-тестування сервісів-оркестраторів

Помилка № 1: Instant.now() і «плаваючі» тести.
Найчастіша проблема — залишити час усередині сервісу некерованим. Тоді розробник починає писати перевірки «не null» або порівняння за діапазоном, і тест перестає бути точним доказом. У результаті регресія виду «ставимо не submittedAt, а createdAt» легко проскакує.

Помилка № 2: «Поточний користувач» береться з глобального стану.
Якщо сервіс усередині себе читає System.getProperty("user.name") або, що ще гірше, напряму лізе в security-контекст, unit-тест або стає неможливим, або перетворюється на крихку конструкцію з моків глобальних синглтонів. Набагато простіше й чистіше передавати CurrentUserProvider як залежність.

Помилка № 3: тест оркестратора починає тестувати алгоритм slug.
Часто хочеться «заодно» перевірити, що "Spring Boot" став "spring-boot". Але тоді ви дублюєте тести SlugService, і при будь-якій зміні правил нормалізації у вас падає половина набору. У тесті ArticleWorkflowService достатньо замокати SlugService і перевірити, що результат потрапив у статтю.

Помилка № 4: перевіряється тільки verify(save) без перевірки вмісту.
Такий тест часто виглядає зеленим і майже нічого не доводить. Сервіс міг зберегти статтю без автора, без slug і без часу — і ви б цього не помітили. Якщо сервіс збирає об’єкт, використовуйте ArgumentCaptor і перевіряйте ключові поля, які й становлять сенс бізнес-дії.

Помилка № 5: надмірне мокування й зайвий контроль взаємодій.
Іноді тест починає перевіряти кожну дрібницю: скільки разів викликали toSlug, у якому порядку викликали policy, які проміжні методи чіпали. Такий тест ламається від будь-якого безневинного рефакторингу й починає дратувати команду. Краще перевіряйте спостережуваний результат — що зберегли і які поля встановили, — та один-два справді важливих моменти взаємодії.

1
Задача
Spring Test, 5 рівень, 2 лекція
Недоступна
Створення чернетки під повним контролем тесту
Створення чернетки під повним контролем тесту
1
Задача
Spring Test, 5 рівень, 2 лекція
Недоступна
Відправлення статті на рев’ю з керованим часом
Відправлення статті на рев’ю з керованим часом
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ