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[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, какие промежуточные методы трогали. Такой тест ломается от любого безобидного рефакторинга и начинает раздражать команду. Лучше проверяйте наблюдаемый результат — что сохранили и какие поля выставили, — и один-два действительно важных момента взаимодействия.

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