JavaRush /Курсы /Spring Test /Create draft → submit for review

Create draft → submit for review

Spring Test
22 уровень , 0 лекция
Открыта

1. Сценарий create draft → submit как regression candidate

Если смотреть на backend глазами начинающего разработчика, то кажется: «Ну что там сложного — два эндпоинта, два HTTP-запроса». Но именно в таких «простых» местах чаще всего и рождаются регрессии, потому что они затрагивают сразу несколько слоёв. В ContentHub сценарий создания черновика и отправки на ревью — это входная дверь в весь publication workflow: если эта дверь заклинивает, дальше уже нечего тестировать, потому что бизнес-процесс не стартует.

Тут важно быстро договориться о терминах, чтобы дальше мы разговаривали не как два разных человека, один про “аннотацию”, другой про “боль в проде”.

Термин Простое объяснение В нашем сценарии
Regression suite Небольшой набор дорогих сквозных тестов, который регулярно запускают, чтобы поймать поломки ключевого поведения Несколько тестов на основные потоки: submit, publish, reject, archive
Regression candidate Сценарий, который претендует на место в regression suite, потому что его поломка заметна и дорогая create draft → submit — ломается быстро и больно
Regression checkpoint Промежуточная проверка внутри длинного сценария, чтобы тест был диагностируемым После create: есть id, статус DRAFT; после submit: статус IN_REVIEW
Наблюдаемый результат То, что мы можем проверить извне: HTTP-ответом и/или состоянием данных Статусы, timestamps, факт сохранения в БД

И теперь главный смысл: наш regression-тест не должен превращаться в музей всех проверок мира. Мы уже закрыли контрактные детали DTO через @JsonTest, а HTTP-механику и валидацию — через @WebMvcTest. В regression-тесте мы оставляем только то, что даёт сигнал «сквозной поток жив», и при этом не превращает тест в хрупкую простыню.

2. Границы сценария и checkpoint-мышление

У длинного интеграционного теста есть типичная проблема: он падает, и вы видите красный цвет… но не понимаете, где именно случилась беда. Это похоже на ситуацию, когда вы пришли в кафе, заказали кофе, десерт и суп, а вам принесли “что-то не то”. Вопрос: а что именно? Кофе был холодный? Десерт солёный? Суп — это вообще был чай? Без чекпоинтов вы будете гадать.

Поэтому мы формулируем границы сценария явно. Старт — статьи не существует. Финал — статья существует и находится в статусе IN_REVIEW (то есть успешно прошла ветку модерации OK/WARN). И между ними мы ставим несколько контрольных точек, которые проверяются через наблюдаемые результаты.

Небольшая схема сценария:

flowchart TD
    A["POST /api/editor/articles create draft"] --> B["Checkpoint 1 201 Created, id != null, status=DRAFT"]
    B --> C["POST /api/editor/articles/{id}/submit submit for review"]
    C --> D["Checkpoint 2 status=IN_REVIEW"]
    D --> E["Final proof DB read: Article.status=IN_REVIEW"]

Заметьте важную вещь: чекпоинты — это не “давайте проверим всё подряд”. Это точки, которые помогают диагностировать падение. Если упало на первом чекпоинте — проблема в создании. Если первый прошёл, а второй упал — проблема в submit-механике, модерации или переходе статуса.

3. Режим теста: @SpringBootTest + @AutoConfigureMockMvc

Когда вы знаете два режима — full-context MockMvc и live-server RANDOM_PORT — рука может потянуться к «самому честному»: поднять реальный порт и гонять HTTP “как в жизни”. Но regression suite — это всегда компромисс между доказательностью и ценой. Если каждый тест будет “как в жизни”, вы быстро получите набор, который запускается как сериал: начал утром — закончил к обеду, а потом пошёл за кофе, потому что смотреть на прогресс-бар больно.

Для сценария create draft → submit for review full-context + MockMvc часто даёт очень хороший ROI. Мы действительно проверяем wiring слоёв (controller → service → repository → интеграционные адаптеры), проверяем сериализацию, валидацию, error handling и кучу других вещей, но при этом не платим за реальный TCP/HTTP round-trip.

И ещё одна важная деталь: в regression-сценариях нам почти всегда нужен доступ к базе для финального доказательства. Не чтобы «протестировать репозиторий» (это уже делали @DataJpaTest), а чтобы подтвердить итоговое состояние бизнес-объекта. Поэтому мы будем сочетать HTTP-вызовы и финальную проверку через ArticleRepository.

4. Каркас тест-класса

Когда пишешь regression-тест, хочется начать с самого “вкусного”: сразу писать mvc.perform(...). Но правильнее сначала собрать каркас и договориться, какую ветку поведения мы фиксируем. В submit-flow у нас есть несколько исходов (например, OK/WARN ведёт в IN_REVIEW, а BLOCK — в REJECTED). В одном конкретном regression-тесте мы выбираем одну ветку, иначе тест превращается в “комбайн” с несколькими исходами и становится нечитаемым.

Базовый regression skeleton здесь такой же, как и у других business-flow в suite: @SpringBootTest + @AutoConfigureMockMvc + @ActiveProfiles("test"). Локальный properties ниже не заменяет test profile, а просто фиксирует нужную ветку модерации поверх него.

Ниже — минимальный каркас. Обратите внимание на properties: это простой способ зафиксировать ветку модерации в тесте. Точное имя свойства зависит от вашего проекта, но смысл такой: тест должен управлять внешними решениями, иначе он будет зависеть от «случайного настроения» стаба.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;

@SpringBootTest(properties = "contenthub.moderation.test-decision=OK") // Локально фиксируем ветку модерации поверх test-profile
@AutoConfigureMockMvc // Поднимаем полный контекст Spring и настраиваем MockMvc без реального порта
@ActiveProfiles("test") // Общая тестовая среда для regression suite
class SubmitForReviewRegressionTest {

    @Autowired
    MockMvc mvc; // Главный инструмент: делаем HTTP-запросы к контроллерам и проверяем ответы
}

Если у вас ещё есть ArticleRepository, мы добавим его чуть позже — он понадобится для финальной проверки состояния. И да, технически можно было бы проверять финал через GET /api/editor/articles/{id}, но чтение из репозитория обычно короче и быстрее, а нам важен сигнал о сохранённом состоянии.

5. Checkpoint 1: create draft и id

Создание черновика — это первый шаг workflow, и именно здесь тесты часто начинают «расти волосами»: кто-то проверяет все поля ответа, кто-то пытается засунуть туда половину домена, кто-то случайно начинает тестировать JSON-контракт заново. Мы делаем наоборот: отправляем минимально валидный request, проверяем только ключевые признаки успеха, извлекаем id и идём дальше.

Сначала добавим репозиторий и необходимые импорты для assert’ов. Импорты можно держать аккуратными — регрессия не должна выглядеть как энциклопедия.

import org.junit.jupiter.api.Test; // Аннотация теста (в проекте используйте JUnit 6)
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void createDraft_thenSubmit_movesArticleToInReview() throws Exception {
    // Arrange/Act/Assert допишем ниже: сейчас это просто каркас сценария
}

Теперь нам нужен helper createDraft(...), который делает HTTP-запрос, проверяет базовые признаки успеха и возвращает id. Важный момент: helper здесь допустим, потому что он убирает транспортный шум (парсинг JSON), но не скрывает, что именно мы делаем.

private Long createDraft(String title) throws Exception {
    // Готовим минимально валидный JSON: только то, что нужно для создания черновика
    String json = """
        {"title":"%s","summary":"Short summary","body":"Text","category":"java"}
        """.formatted(title);

    // Делаем POST create draft и фиксируем Checkpoint 1: HTTP 201 + status=DRAFT
    String body = mvc.perform(post("/api/editor/articles")
                    .contentType(MediaType.APPLICATION_JSON) // Говорим контроллеру, что это JSON
                    .content(json)) // Тело запроса
            .andExpect(status().isCreated()) // Checkpoint: черновик реально создан
            .andExpect(jsonPath("$.status").value("DRAFT")) // Checkpoint: бизнес-статус на первом шаге
            .andReturn().getResponse().getContentAsString(); // Забираем тело ответа, чтобы вытащить id

    // Возвращаем id созданной статьи: он понадобится для submit шага
    return ((Number) com.jayway.jsonpath.JsonPath.read(body, "$.id")).longValue();
}

И ещё одна граница скоупа. В этом сценарии мы доказываем сам business-flow и его эффекты на данных: черновик создался, затем ушёл в IN_REVIEW. Предполагаем, что запрос делает допустимый editor; матрицу 401/403, роли и owner-based access не тащим внутрь каждого такого regression-теста, иначе ядро suite быстро распухнет.

Теперь пара ремарок, которые сильно экономят нервы. Если у вас в проекте editor-endpoints защищены, и вы получаете 401, не нужно паниковать и подозревать “сломанный контроллер”. Обычно это означает, что вы просто не передали аутентификацию. Самый простой вариант — добавить к запросу basic auth или test-user postprocessor. Например, в строку mvc.perform(...) можно добавить .with(httpBasic("editor", "editor-pass")), если вы используете HTTP Basic. Если security в тестовом профиле отключён, это не понадобится — но принцип остаётся: regression-тест должен ясно “представляться” тем пользователем, который реально может сделать действие.

Почему мы проверяем именно status=DRAFT, а не всё остальное? Потому что “черновик создался” — это бизнес-результат, который влияет на дальнейший workflow. Проверять в regression-тесте все поля ответа — это повторение JSON layer и web-slice, за которое мы уже заплатили раньше.

6. Checkpoint 2: submit и статус IN_REVIEW

Теперь начинается самое интересное: второй шаг сценария должен реально пройти через несколько слоёв и сменить состояние статьи. Именно тут часто появляются регрессии от «маленького рефакторинга»: кто-то поменял policy, кто-то переименовал enum, кто-то забыл сохранить submittedAt, а кто-то “чуть-чуть” подкрутил интеграцию с модерацией.

Сделаем helper submitForReview(id) и затем финальную проверку через репозиторий. Сама отправка на review может возвращать обновлённую статью или просто 200 OK — в обоих случаях нам важны два доказательства: успешный HTTP-ответ и сохранённое состояние в БД.

private void submitForReview(Long id) throws Exception {
    // Второй шаг сценария: отправляем уже созданную статью на ревью
    mvc.perform(post("/api/editor/articles/{id}/submit", id))
            .andExpect(status().isOk()); // Checkpoint: команда принята и обработана без ошибки
}

Теперь подключим репозиторий и проверим итог. Здесь мы добавим AssertJ, потому что он более читабелен, чем набор assertEquals, и мы уже договорились о нём как о базовом стиле курса.

import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;

@Autowired
ArticleRepository articleRepository; // Финальное доказательство делаем через чтение из БД

@Test
void createDraft_thenSubmit_movesArticleToInReview() throws Exception {
    // Act: создаём черновик
    Long id = createDraft("Intro to Spring");

    // Act: отправляем на ревью
    submitForReview(id);

    // Assert: перечитываем состояние из БД и проверяем итоговый бизнес-статус
    Article article = articleRepository.findById(id).orElseThrow();
    assertThat(article.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
}

Если вы хотите усилить доказательство (и при этом не скатиться в “проверим всё”), можно добавить проверку, что проставлено submittedAt. Мы не сравниваем точное время, потому что это часто приносит больше проблем, чем пользы, если вы не фиксировали Clock в тестовом профиле. Но проверить “не null” — обычно отличный checkpoint.

assertThat(article.getSubmittedAt()).isNotNull(); // Дополнительный checkpoint: факт submit-а записан в данные

В итоге получается хороший сквозной сигнал: система приняла команду создать черновик, вернула идентификатор, затем приняла команду отправить на ревью и реально перевела статью в статус IN_REVIEW. Это и есть то, что регрессионный тест должен доказывать: не “мы вызвали метод”, а “система прошла путь”.

7. Читаемость regression-теста

Когда тест становится “сквозным”, появляется соблазн построить вокруг него маленький фреймворк. Сегодня вы сделаете createDraft(...), завтра — submit(...), послезавтра — approve(...), потом это превратится в DSL givenDraft().whenSubmit().thenStatusIs(...), и внезапно у вас есть второй Spring внутри тестов. Это почти всегда плохо для обучения и часто плохо для реального проекта, потому что падение теста становится сложно объяснить новичку.

Здоровый компромисс выглядит так: helpers должны убирать рутину и повтор, но оставлять видимыми шаги сценария. Тест-метод должен читаться как мини-история: «создали → отправили → проверили». Имена helpers должны быть доменными, а не техническими. createDraft звучит как бизнес-операция. postToEditorControllerAndParseJsonId звучит как наказание.

Иногда полезно разделить внутри теста три части AAA (Arrange–Act–Assert), но без фанатизма. В regression-тесте “Arrange” — это обычно стартовое состояние (у нас оно почти пустое), “Act” — два HTTP-запроса, “Assert” — финальная проверка статуса. Если вы сделаете это очевидным, тест будет приятнее поддерживать, и вам будет легче понять, что сломалось, когда он станет красным.

8. Типичные ошибки в regression-тесте

Ошибка №1: сделать один мегатест “на всё”.
Иногда студент (или разработчик, что страшнее) пытается в один метод запихнуть создание, редактирование, submit, approve и даже проверку public API. В итоге тест становится длинным, хрупким и плохо диагностируемым: он может упасть где угодно, и вы тратите время не на поиск дефекта, а на археологию. В regression suite лучше держать один тест — один основной поток с ясным финалом.

Ошибка №2: проверять абсолютно все поля JSON-ответа.
Это выглядит как забота о качестве, но чаще всего это дублирует более дешёвые уровни тестов. JSON-контракт DTO вы уже стабилизировали @JsonTest, а web-детали — в MVC slice. Regression-тесту достаточно нескольких полей, которые доказывают смысл: id, status, возможно slug или submittedAt. Чем меньше шума — тем выше ценность сигнала.

Ошибка №3: ограничиться только HTTP-статусами и не перечитать состояние.
200 OK после submit не гарантирует, что статья реально перешла в нужный статус. Где-то могла “проглотиться” ошибка сохранения, где-то могла не выполниться часть workflow, где-то могли поменяться транзакционные границы. Финальная проверка через повторное чтение (репозиторий или отдельный GET) превращает тест из “мы нажали кнопку” в “система изменила состояние”.

Ошибка №4: смешать в одном тесте разные ветки модерации.
Submit-for-review в ContentHub имеет альтернативные исходы. Если вы в одном и том же тесте пытаетесь покрыть и OK, и BLOCK, тест превращается в два сценария сразу, а падение становится менее очевидным. Держите одну ветку — один тест. Для другой ветки будет другой тест, с другой подготовкой и другими ожиданиями.

Ошибка №5: спрятать ключевые предпосылки в слишком умных helper-методах.
Helper — это хорошо, пока он уменьшает шум. Но если helper начинает “внутри себя” создавать категории, подготавливать пользователей, менять свойства, а потом “как-то” делать submit, то тест перестаёт быть читаемым сценарием. В regression suite лучше чуть больше явности, чем чуть больше магии. Да, это тот редкий случай, когда “ясно” важнее, чем “красиво”.

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