1. ArticleWorkflowService: оркестрация без случайностей
Если PublicationPolicy — это почти учебник по «чистой логике», то ArticleWorkflowService — уже жизнь. Сервис-оркестратор знает чуть больше: он не просто отвечает «можно/нельзя», а собирает объект, ставит временные метки, подставляет автора, дёргает генератор slug и отправляет результат в репозиторий. И если хотя бы одна из этих вещей берётся «из воздуха» — например, через Instant.now() — тест превращается в гадание на кофейной гуще: иногда зелёный, иногда — «ну… почти зелёный».
Самая важная мысль этой лекции звучит очень приземлённо: unit-тест сервиса-оркестратора должен управлять всем, что может быть случайным. В ContentHub такими источниками «рандома» почти всегда становятся текущее время, текущий пользователь и служебные вычисления вроде slug. Это не магия и не философия — просто инженерная гигиена.
Давайте сначала зафиксируем, что именно обычно делает ArticleWorkflowService. В реальном проекте методов будет больше, но нам достаточно понятной схемы: создать черновик, отправить на ревью, опубликовать, отклонить.
flowchart LR
S[ArticleWorkflowService] --> P[PublicationPolicy]
S --> R[ArticleRepository]
S --> U[CurrentUserProvider]
S --> C[Clock]
S --> SL[SlugService]
Сервис стоит в центре: он соединяет «локальные правила» (policy), «вычисления» (slug), «контекст» (user/time) и «границу хранения» (repository). И это отлично тестируется unit-тестом — если не пытаться одновременно поднять Spring, БД, HTTP и прочие радости жизни.
2. Видимые зависимости: constructor injection
Когда начинающий разработчик пишет сервис, перед ним обычно два пути. Первый — быстрый и опасный: внутри метода вызвать Instant.now(), «как-нибудь» узнать пользователя, внутри же создать new SlugService(), а потом удивляться, почему тесты выглядят как борьба с гидрой. Второй путь — скучный, правильный и в итоге приятный: все зависимости приходят в конструктор, а тест сам решает, что именно считать «сейчас», кто пользователь и какой slug получится.
Нам не нужно переписывать весь production-код в этой лекции, но полезно увидеть мини-фрагмент, который делает тесты возможными. Суть в том, что время берётся из Clock, пользователь — из CurrentUserProvider, slug — из SlugService, а сохранение идёт через ArticleRepository. Политику мы обычно оставляем реальной (её мы уже проверили в лекции 2), потому что она дешевле и честнее, чем мок.
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-layer мы делаем простой интерфейс, который возвращает имя пользователя. Он не обязан быть большим и умным — чем проще, тем лучше. И да, это как раз тот случай, когда лямбда — не зло, а лекарство от лишней церемонии.
@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-layer. Причина проста: настоящий репозиторий почти всегда означает базу данных, транзакции, конфигурацию и прочие вещи, которые мы сознательно не включаем сегодня. Но проблема появляется тут же: если мы просто сделаем 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, какие промежуточные методы трогали. Такой тест ломается от любого безобидного рефакторинга и начинает раздражать команду. Лучше проверяйте наблюдаемый результат — что сохранили и какие поля выставили, — и один-два действительно важных момента взаимодействия.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ