1. Статусные переходы как бизнес-правила
Если вы хоть раз видели код в стиле «ну тут просто поменяем статус», то уже знаете, как рождаются баги класса «мы случайно разрешили невозможное». Статусы статьи — это не декоративные наклейки, а маленький workflow, который живёт дольше конкретных контроллеров, DTO и даже конкретной базы данных. Поэтому правила переходов полезно вынести в отдельный класс, который можно тестировать быстро, локально и без Spring — как обычную математику, только с драмой.
Представьте «плохой» путь: логика переходов размазана по сервисам и контроллерам, и каждый раз кто-то добавляет новый endpoint и делает «ну вроде логично»:
// где-то в сервисе, грустно и без охраны
if (article.getStatus() == ArticleStatus.DRAFT) {
// Плохой пример: "прямая публикация" минует ревью и ломает workflow
article.setStatus(ArticleStatus.PUBLISHED); // ой
}
Этот код компилируется. Он даже может пройти ручную проверку, если тестировщик не дошёл до конкретного сценария. Но он ломает смысл домена: публикация должна происходить из IN_REVIEW, а не из DRAFT. Именно такие вещи мы и хотим ловить дешёвыми unit-тестами, потому что это «ошибка смысла», а не ошибка Spring-конфигурации.
Вот почему мы вводим PublicationPolicy — объект, который отвечает на вопрос: «Можно ли перейти из статуса A в статус B?» В самом простом виде нам достаточно метода:
boolean canMove(ArticleStatus from, ArticleStatus to);
И всё. Никаких репозиториев, времени и текущего пользователя. Только правило.
2. Статусы статьи ContentHub
Прежде чем тестировать переходы статусов, нужно договориться о языке. В ContentHub статусы — это не «пять строк в enum ради красоты», а мини-модель жизненного цикла статьи. Когда вы пишете тест, вы фиксируете не только поведение метода, но и смысл продукта: что такое черновик, что значит «на ревью» и почему архив — это почти «покойся с миром» для статьи.
Начнём с enum. В проекте он выглядит примерно так:
public enum ArticleStatus {
DRAFT,
IN_REVIEW,
PUBLISHED,
REJECTED,
ARCHIVED
}
А теперь — по-человечески:
DRAFT — статья ещё «в работе». Её можно править, дополнять, шлифовать. Если из этого статуса кто-то умудрился опубликовать статью напрямую, значит workflow у нас работает «на доверии». А доверие — вещь прекрасная, но дебажится плохо.
IN_REVIEW — статья отправлена на ревью. С точки зрения домена это точка контроля: либо статью одобрят и опубликуют, либо отклонят. И важно, что переходы отсюда ограничены: если вы внезапно сделаете IN_REVIEW → DRAFT без явного сценария отмены, это уже изменение продукта, а не просто «поправил код».
PUBLISHED — статья видна публично. Этот статус должен подчиняться строгим правилам, потому что любая ошибка здесь «утечёт наружу»: пользователь увидит в публичном API то, что публиковать было нельзя.
REJECTED — статья отклонена. Обычно у неё есть причина отказа, но это отдельная тема; здесь мы говорим только о статусе. Важно, что отклонение не равно удалению: статья остаётся в системе, но workflow у неё уже другой.
ARCHIVED — статья выведена из активного оборота. В учебной модели курса это терминальное состояние: логика «давайте вернём архив в черновик» — уже отдельная фича и точно не должна появиться «случайно».
3. Матрица переходов статусов
Переходы статусов удобно рассматривать как конечный автомат: есть состояния (статусы) и есть допустимые «стрелки» между ними. Такой взгляд сразу подсказывает, что тестировать: не «метод вызвался», а «стрелка существует или нет». В реальных проектах чаще всего ломаются именно стрелки — потому что кто-то «немного упростил» или «временно разрешил», а потом это «временно» остаётся навсегда.
В учебной модели ContentHub отправка статьи проходит через предварительную автоматическую проверку модерации. Если результат OK или WARN, материал можно перевести в IN_REVIEW; если результат BLOCK, система сразу уводит его в REJECTED. Нам здесь не нужна сама интеграция с модерацией — важен уже зафиксированный доменный итог, потому что policy должна уметь отвечать и за такую ветку тоже.
Сначала зафиксируем наглядную картинку. Для нашего учебного workflow полезно держать в голове такую схему:
stateDiagram-v2
[*] --> DRAFT
DRAFT --> IN_REVIEW : "submit (OK/WARN)"
DRAFT --> REJECTED : "submit (BLOCK)"
IN_REVIEW --> PUBLISHED : approve
IN_REVIEW --> REJECTED : reject
REJECTED --> DRAFT : rework
PUBLISHED --> ARCHIVED : archive
ARCHIVED --> ARCHIVED : "(stay)"
Теперь — матрица: по строке мы выбираем from, по колонке — to. Будем считать, что переход «в тот же статус» допустим: это не смена состояния, а no-op, и policy не должна мешать таким кейсам. Если хочется строже, можно запретить from == to, но тогда придётся объяснять это всем, кто случайно вызвал canMove(DRAFT, DRAFT).
| from \ to | DRAFT | IN_REVIEW | PUBLISHED | REJECTED | ARCHIVED |
|---|---|---|---|---|---|
| DRAFT | да | да | нет | да | нет |
| IN_REVIEW | нет | да | да | да | нет |
| PUBLISHED | нет | нет | да | нет | да |
| REJECTED | да | нет | нет | да | нет |
| ARCHIVED | нет | нет | нет | нет | да |
Зачем нам эта таблица в лекции, если мы всё равно пишем код? Потому что это и есть спецификация. Тесты будут фактически проверять, что PublicationPolicy соответствует этой матрице. И если через полгода кто-то захочет добавить возврат из архива, он сначала изменит матрицу и тесты, а потом реализацию. Это намного лучше, чем «случайно заработало».
4. Минимальная реализация PublicationPolicy
Перед тестами нам нужна минимальная реализация, но здесь важно не увлечься. Мы не строим «идеальную архитектуру policy engine», нам нужен маленький класс с чистым методом. Статусов всего пять, поэтому обычный switch по from будет самым читаемым решением. И да, иногда лучший паттерн называется «не усложняй».
Один из простых вариантов — такой метод:
import static com.example.contenthub.ArticleStatus.*;
public class PublicationPolicy {
public boolean canMove(ArticleStatus from, ArticleStatus to) {
// Договорённость: "переход в тот же статус" считаем no-op и разрешаем
if (from == to) return true;
// Здесь намеренно нет репозиториев/пользователей/времени: только бизнес-правило
return switch (from) {
case DRAFT -> to == IN_REVIEW || to == REJECTED; // черновик: на ревью или в отклонённые
case IN_REVIEW -> to == PUBLISHED || to == REJECTED; // ревью: либо публикуем, либо отклоняем
case REJECTED -> to == DRAFT; // отклонено: возвращаем на доработку
case PUBLISHED -> to == ARCHIVED; // опубликовано: можно только архивировать
case ARCHIVED -> false; // архив: терминальное состояние
};
}
}
Обратите внимание на важную мысль: тесты будут проверять поведение canMove, а не то, используете ли вы switch, EnumMap, магический YAML или гадание на кофейной гуще. В unit-тестах нас интересует не внутренняя реализация, а контракт «разрешено/запрещено».
5. Первые unit-тесты: разрешённые переходы
В хороших unit-тестах мы начинаем с самого очевидного и полезного: подтверждаем, что ключевые разрешённые переходы действительно разрешены. Это похоже на проверку дверей: прежде чем искать, какие замки не работают, надо убедиться, что нужные двери вообще открываются. Здесь мы используем обычный @Test и AssertJ — максимально простой формат, чтобы не возникало ощущения, будто «тестирование = магия аннотаций».
Начнём с самого базового: DRAFT → IN_REVIEW. Это отправка на ревью по ветке OK/WARN после модерации.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
// Тестируем чистую доменную логику: никакого Spring, никакой БД
private final PublicationPolicy policy = new PublicationPolicy();
@Test
void draftCanMoveToInReview() {
// Базовый сценарий: черновик можно отправить на ревью
assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW)).isTrue();
}
}
Дальше проверим, что статья на ревью может стать опубликованной: IN_REVIEW → PUBLISHED. Это сценарий «approve».
@Test
void inReviewCanMoveToPublished() {
assertThat(policy.canMove(ArticleStatus.IN_REVIEW, ArticleStatus.PUBLISHED)).isTrue();
}
И ещё один важный переход: PUBLISHED → ARCHIVED. Это жизненный сценарий «архивировать публикацию».
@Test
void publishedCanMoveToArchived() {
assertThat(policy.canMove(ArticleStatus.PUBLISHED, ArticleStatus.ARCHIVED)).isTrue();
}
На этом этапе часто хочется «проверить сразу всё», но я бы чуть притормозил: нужен баланс между подробностью и читабельностью. Уже эти тесты выглядят как документация: открыл файл — и сразу увидел, как живёт публикация статьи.
6. Негативные сценарии: запрещённые переходы
Самая частая ошибка в тестах на бизнес-правила — проверять только то, что «разрешено», и забывать про то, что «запрещено». Но именно запреты защищают домен от «случайной демократии» в коде. Если вы не тестируете запрет, кто-то однажды обязательно «для удобства» разрешит переход, который ломает продуктовую логику. И этот кто-то часто будете вы сами — просто через две недели, когда уже забудете, почему так нельзя.
Самый классический запрет в нашем workflow: нельзя публиковать черновик напрямую. Значит, DRAFT → PUBLISHED должно быть запрещено.
@Test
void draftCannotMoveDirectlyToPublished() {
assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.PUBLISHED)).isFalse();
}
Дальше — терминальность архива. Если статья в архиве, она не должна «воскресать» обратно в черновик. Значит, ARCHIVED → DRAFT запрещён.
@Test
void archivedArticleCannotReturnToDraft() {
assertThat(policy.canMove(ArticleStatus.ARCHIVED, ArticleStatus.DRAFT)).isFalse();
}
Ещё полезная проверка, которая часто ловит странные баги: из PUBLISHED нельзя «отклонить» статью (PUBLISHED → REJECTED). Отклонение — это решение ревью, а не реакция на уже опубликованное.
@Test
void publishedCannotMoveToRejected() {
assertThat(policy.canMove(ArticleStatus.PUBLISHED, ArticleStatus.REJECTED)).isFalse();
}
Когда вы покрываете отрицательные сценарии, тесты начинают работать как охранник на входе: «нет, из этого статуса туда нельзя, даже если очень хочется и уже пятница».
7. Параметризованные тесты: живая матрица
Когда переходов становится много, писать по одному @Test на каждую стрелку быстро превращается в простыню из однотипных кейсов. И тут на сцену выходит параметризация: один тест, много входных данных. Но важно не скатиться в другую крайность: параметризованный тест должен оставаться читаемым, а не превращаться в чёрный ящик из серии «вроде всё проверили, а что именно — неясно».
Для начала заведём маленький record, чтобы было удобно хранить пары статусов. Это просто типобезопасная коробочка:
record Transition(ArticleStatus from, ArticleStatus to) { }
Теперь зафиксируем список разрешённых переходов как спецификацию. Мы не берём этот список из PublicationPolicy — это была бы «проверка через зеркало» — а задаём его прямо в тесте как то, что домен считает правильным.
import org.junit.jupiter.params.provider.Arguments;
import java.util.stream.Stream;
import static org.junit.jupiter.params.provider.Arguments.arguments;
static Stream<Arguments> allowedTransitions() {
// Спецификация домена: "что считаем разрешённым" (не вытаскиваем из реализации!)
return Stream.of(
arguments(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW),
arguments(ArticleStatus.DRAFT, ArticleStatus.REJECTED),
arguments(ArticleStatus.IN_REVIEW, ArticleStatus.PUBLISHED),
arguments(ArticleStatus.IN_REVIEW, ArticleStatus.REJECTED),
arguments(ArticleStatus.REJECTED, ArticleStatus.DRAFT),
arguments(ArticleStatus.PUBLISHED, ArticleStatus.ARCHIVED)
);
}
И сам параметризованный тест:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@ParameterizedTest(name = "{0} -> {1} is allowed")
@MethodSource("allowedTransitions")
void allowsKnownTransitions(ArticleStatus from, ArticleStatus to) {
// Один тест проверяет сразу набор разрешённых "стрелок" из матрицы
assertThat(policy.canMove(from, to)).isTrue();
}
Теперь сделаем симметричный тест на запрещённые переходы. И тут есть приятный трюк: запрещённые переходы — это все остальные пары, кроме разрешённых и кроме from == to, если мы считаем no-op допустимым. Поэтому их можно сгенерировать из кросс-произведения значений enum, а не выписывать руками двадцать строк.
Сначала зафиксируем разрешённые пары как Set:
import java.util.Set;
private static final Set<Transition> ALLOWED = Set.of(
// Этот набор — "истина" для тестов: именно его сравниваем с поведением policy
new Transition(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW),
new Transition(ArticleStatus.DRAFT, ArticleStatus.REJECTED),
new Transition(ArticleStatus.IN_REVIEW, ArticleStatus.PUBLISHED),
new Transition(ArticleStatus.IN_REVIEW, ArticleStatus.REJECTED),
new Transition(ArticleStatus.REJECTED, ArticleStatus.DRAFT),
new Transition(ArticleStatus.PUBLISHED, ArticleStatus.ARCHIVED)
);
Теперь метод-источник для запрещённых переходов:
static Stream<Arguments> forbiddenTransitions() {
return Stream.of(ArticleStatus.values())
// Генерируем все пары (from, to) как кросс-произведение
.flatMap(from -> Stream.of(ArticleStatus.values())
.map(to -> new Transition(from, to)))
// no-op переходы (from == to) мы не считаем "запрещёнными"
.filter(t -> t.from() != t.to())
// Оставляем только те пары, которых нет в белом списке
.filter(t -> !ALLOWED.contains(t))
.map(t -> arguments(t.from(), t.to()));
}
И тест:
@ParameterizedTest(name = "{0} -> {1} is forbidden")
@MethodSource("forbiddenTransitions")
void forbidsAllOtherTransitions(ArticleStatus from, ArticleStatus to) {
assertThat(policy.canMove(from, to)).isFalse();
}
В итоге получается почти буквальная автоматизированная версия матрицы. Это уже не «несколько кейсов для галочки», а настоящая защита бизнес-логики.
8. Переходы с исключением: checkMove
Иногда одного canMove мало. В реальном коде почти всегда нужен более «силовой» вариант: не просто «вернул false и пошли дальше», а явная ошибка при запрещённом переходе. Для этого часто добавляют метод, который бросает исключение. С точки зрения теста всё просто: вызываем метод и проверяем, что он падает нужным типом.
Мини-метод выглядит так:
public void checkMove(ArticleStatus from, ArticleStatus to) {
if (!canMove(from, to)) {
throw new InvalidStatusTransitionException(from, to);
}
}
И простое исключение:
public class InvalidStatusTransitionException extends RuntimeException {
public InvalidStatusTransitionException(ArticleStatus from, ArticleStatus to) {
super("""
Invalid status transition:
from=%s
to=%s
""".formatted(from, to));
}
}
Теперь тест:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Test
void checkMoveThrowsForDraftToPublished() {
assertThatThrownBy(() -> policy.checkMove(ArticleStatus.DRAFT, ArticleStatus.PUBLISHED))
.isInstanceOf(InvalidStatusTransitionException.class)
.hasMessageContaining("DRAFT");
}
Почему это полезно? Потому что дальше, в сервисе, можно писать не «if canMove then …», а «policy.checkMove(…)», и бизнес-ошибка будет проявляться честно: без тихих false и без странного поведения по цепочке.
9. Типичные ошибки при тестировании PublicationPolicy
Ошибка №1: тестировать только разрешённые переходы и игнорировать запрещённые.
Такой набор тестов создаёт ложное чувство безопасности: вы доказали, что двери открываются, но не доказали, что в запретные комнаты нельзя пройти. В статусных workflow именно запреты ловят самые дорогие регрессии, потому что «случайно разрешённая публикация» почти всегда становится production-инцидентом, а не просто «косметическим багом».
Ошибка №2: проверять матрицу «через реализацию», а не через спецификацию.
Иногда разработчик делает в тесте что-то вроде «вызову policy.allowedTransitions() и проверю, что оно равно policy.allowedTransitions()». Формально тест зелёный, но он ничего не доказывает. Хороший тест задаёт правила снаружи — как список разрешённых переходов — и проверяет только публичный метод canMove или checkMove.
Ошибка №3: смешивать в этих тестах пользователя, время, сохранение в БД и всё остальное.
Если вы в тесте PublicationPolicy вдруг ловите себя на мысли «а где взять текущего пользователя?» — вы случайно уехали в другой слой. PublicationPolicy должен быть максимально «тупым» в хорошем смысле: входные статусы → ответ. Всё, что зависит от ролей, времени, репозиториев и интеграций, не относится к этому unit-target.
Ошибка №4: превращать параметризованный тест в нечитаемую «магическую генерацию».
Да, можно написать 30 строк стримов, которые генерируют матрицу, красиво фильтруют и ещё сортируют по алфавиту. Но если через месяц вы сами не сможете понять, почему переход запрещён, вы проиграли. Держите генерацию простой и рядом со спецификацией (ALLOWED), а самые важные запреты — вроде DRAFT → PUBLISHED — всё равно полезно иметь отдельными тестами-«маяками».
Ошибка №5: забыть договориться, что делать с from == to.
Если вы явно не решите, разрешён ли «переход в тот же статус», у вас появится странный плавающий баг: в одном месте кода кто-то вызовет canMove(DRAFT, DRAFT), получит false и решит, что «нельзя сохранять черновик», хотя статус просто не менялся. Разрешать no-op часто практичнее; запрещать тоже можно, но тогда это должно быть осознанным правилом и отражаться и в тестах, и в названии метода — например, canChangeStatus вместо canMove.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ