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, які проміжні методи чіпали. Такий тест ламається від будь-якого безневинного рефакторингу й починає дратувати команду. Краще перевіряйте спостережуваний результат — що зберегли і які поля встановили, — та один-два справді важливих моменти взаємодії.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ