JavaRush /Курси /Spring Test /Approve → publish: перевірка публікації

Approve → publish: перевірка публікації

Spring Test
Рівень 22 , Лекція 1
Відкрита

1. Publish-flow як кандидат на регресію

Публікація на контентній платформі — це як git push у продакшен: кнопку натиснули — і світ уже не буде колишнім. Усередині застосунку це проявляється зміною статусу та заповненням publishedAt, але для користувача все простіше: стаття зʼявилася в публічній видачі. Якщо ми перевіримо лише успішну відповідь approve endpoint, то отримаємо «зелений тест», який доводить приблизно те саме, що й лампочка на приладовій панелі: світиться, а чи завівся двигун — невідомо.

У ContentHub publish-flow цінний для регресії, бо проходить через довгий ланцюжок: admin endpoint → сервіс workflow → policy переходів → збереження через repository → читання через public endpoint. І в цьому ланцюжку можна зламати що завгодно: від неправильного фільтра status = PUBLISHED у публічному репозиторії до того, що publishedAt перестали заповнювати після «невеликого рефакторингу на пʼятничному релізі».

Для наочності можна тримати в голові просту схему того, що ми хочемо довести одним тестом:

flowchart TD
  A["POST /api/admin/articles/{id}/approve"] --> B[Контролер адміністратора]
  B --> C[Сервіс робочого процесу]
  C --> D[Політика публікації]
  C --> E[ArticleRepository]
  E --> F[(БД)]
  F --> G["GET /api/public/articles/{slug}"]

Цей тест не зобов’язаний бути «найдетальнішим». Він має бути найпереконливішим за розумної ціни.

2. Старт: стаття у IN_REVIEW

Є спокуса написати один тест «усе в одному»: створити чернетку, відправити на review, схвалити, перевірити public API, потім ще заархівувати «про всяк випадок» — і наприкінці додати assertThat(true).isTrue() для моральної підтримки. Такий тест справді буде довгим… але користі від нього буде менше, ніж хотілося б: падатиме він «десь посередині», а діагностувати його буде так само складно, як за картою метро без станцій.

Для publish-flow нам вигідно стартувати зі стану IN_REVIEW. Це робить тест чеснішим і простішим: ми перевіряємо саме те, що хочемо захистити. Create і submit уже мають окремі кандидати на регресію, тож тут не обов’язково починати з порожньої бази.

Найпрозоріший спосіб зафіксувати стартовий стан — @Sql із невеликим «сюжетом»: «є стаття id=100, slug=intro-to-spring, статус IN_REVIEW». Тоді тест читається як історія, а не як конструктор сутностей із 40 рядків.

Приклад файлу src/test/resources/sql/article-in-review.sql (спрощено, щоб побачити ідею):

-- Базова довідкова сутність, щоб зовнішні ключі/валідації не падали на рівному місці
insert into categories(id, code, name)
values (1, 'java', 'Java');

-- Головне для сценарію: стаття існує і перебуває у статусі IN_REVIEW
-- Саме це і є "точка входу" для approve → publish
insert into articles(id, title, slug, summary, body, status, author_username)
values (100, 'Intro to Spring', 'intro-to-spring', 'Short summary', 'Text', 'IN_REVIEW', 'editor1');

Так, у вашому реальному проєкті стовпців буде більше (час, посилання, версії тощо), а міграції Flyway змушуватимуть вас заповнювати not-null поля. Але логіка лишається тією самою: тест має починатися з пояснюваного стану.

3. Каркас тесту і стенд

Коли ми пишемо регресійний тест, нам важлива не кількість анотацій, а те, який шар реальності вони піднімають. Для publish-flow нам потрібен повний wiring застосунку, тому що публікація зав’язана і на бізнес-правила, і на persistence, і на публічне читання. Тому ми беремо @SpringBootTest і @AutoConfigureMockMvc, а стартовий стан фіксуємо @Sql.

@Sql тут — не новий baseline, а просто сценарний seed-state поверх загального regression skeleton @SpringBootTest + @AutoConfigureMockMvc + @ActiveProfiles("test").

Мінімальний каркас може виглядати так:

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.context.jdbc.Sql;

@SpringBootTest // Піднімаємо повний Spring-контекст (потрібен end-to-end по шарах)
@AutoConfigureMockMvc // Робимо можливими HTTP-запити через MockMvc без підняття реального сервера
@ActiveProfiles("test") // Загальне тестове середовище для regression suite
@Sql("/sql/article-in-review.sql") // Сценарний seed-state: стаття id=100 уже в IN_REVIEW
class ApproveAndPublishRegressionTest {
}

Тут @ActiveProfiles("test") — це не формальність. Він відповідає за те, щоб застосунок стартував у передбачуваній тестовій конфігурації: тестова БД, тестові шляхи зберігання, детерміновані прапорці тощо. Інакше ви одного разу випадково запустите тести, а вони спробують записувати вкладення на ваш робочий диск або звернутися до «справжньої» зовнішньої інтеграції. І це вже буде не тест, а пригода.

Додамо залежності, які нам потрібні для перевірок: MockMvc для HTTP і ArticleRepository для читання стану.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

@Autowired
MockMvc mvc; // Викликаємо admin/public HTTP endpoints і перевіряємо відповіді
import org.springframework.beans.factory.annotation.Autowired;
import com.example.contenthub.repository.ArticleRepository;

@Autowired
ArticleRepository articleRepository; // Читаємо стан статті з БД для контрольних точок після approve

Ми свідомо не будуємо тут «ідеальний DSL». Нам важлива читабельність сценарію, а не краса заради краси.

4. Крок approve: 200 OK — не доказ

У publish-flow перший крок — адмін схвалює статтю. На рівні HTTP це зазвичай POST /api/admin/articles/{id}/approve. І тут хочеться зробити одну важливу паузу: перевірка status().isOk() — корисна, але сама по собі занадто слабка. Вона доводить, що endpoint не впав. Але не доводить, що стаття справді стала опублікованою.

Проте цей крок потрібен як «контрольна точка транспортного рівня»: запит дійшов, контролер живий, серіалізація й валідація не вибухнули, ланцюжок обробився. Тому робимо мінімальну перевірку і йдемо далі.

import org.junit.jupiter.api.Test;

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

@Test
void approveEndpoint_returnsOk() throws Exception {
    // Транспортна контрольна точка: endpoint живий, запит проходить і не падає з 5xx/4xx
    mvc.perform(post("/api/admin/articles/{id}/approve", 100L))
            .andExpect(status().isOk());
}

У regression-логіці це можна сховати в helper, щоб основний сценарій читався як «approve → перевірити стан → перевірити public API». Але helper має бути коротким і не перетворюватися на чорну скриньку.

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

private void approve(long articleId) throws Exception {
    // Один виклик — один сенс: адміністратор схвалив статтю, далі перевіряємо наслідки
    mvc.perform(post("/api/admin/articles/{id}/approve", articleId))
            .andExpect(status().isOk()); // Мінімальна перевірка транспорту: запит відпрацював
}

Тут ми не перевіряємо «всі поля відповіді». Для publish-flow це не є головним доказом. Головний сенс — далі.

У publish-flow три лінії доказу: endpoint відпрацював, стаття стала PUBLISHED, public API почав її бачити. Спочатку корисно відокремити ці контрольні точки одна від одної, щоб не змішати сенс перевірок. У реальному regression core їх зазвичай збирають назад в один дорогий тест із 2–3 контрольними точками, а не тримають як набір майже однакових integration-тестів.

5. Контрольна точка №1: PUBLISHED і publishedAt

Після approve ми хочемо довести, що стаття справді змінила статус, і ця зміна зафіксувалася на рівні даних. Бо «endpoint повернув 200» може означати навіть те, що він спрацював вхолосту, або що логіка змінилася і більше не чіпає publishedAt. У regression suite ми надаємо перевагу доказам, які складно «випадково обдурити».

Мінімальний корисний набір перевірок для publish-flow на рівні стану статті зазвичай виглядає так: статус став PUBLISHED, а publishedAt перестав бути null.

import org.junit.jupiter.api.Test;

import com.example.contenthub.entity.Article;
import com.example.contenthub.entity.ArticleStatus;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void approve_publishesArticle_inDatabase() throws Exception {
    // Дія: запускаємо publish-flow із фіксованого стану IN_REVIEW
    approve(100L);

    // Перевірка: читаємо стан заново через repository (перевіряємо саме те, що в БД)
    Article article = articleRepository.findById(100L).orElseThrow();
    assertThat(article.getStatus()).isEqualTo(ArticleStatus.PUBLISHED); // Статус має стати PUBLISHED
    assertThat(article.getPublishedAt()).isNotNull(); // Час публікації має заповнитися
}

Зверніть увагу на стиль: ми не порівнюємо всю статтю цілком. Це збільшує крихкість. Нам потрібен конкретний сигнал: публікація відбулася. Якщо завтра ви додасте в Article поле updatedBy, а воно почне змінюватися, тест, що порівнює весь об’єкт, почне падати від шуму.

Тут також корисно пам’ятати про пастку persistence context. Якщо ви зробите тест транзакційним і почнете працювати з тим самим entity-екземпляром до та після запиту, можна випадково перевірити не те, що лежить у БД, а те, що вже «підправлено» в пам’яті. Найпростіший спосіб жити спокійно — не робити integration test @Transactional за замовчуванням без потреби і щоразу читати кожну контрольну точку через repository заново.

6. Контрольна точка №2: стаття в public API

Тепер ми довели внутрішню істину застосунку: статус і час публікації оновилися. Але publish-flow бізнесово важливий не цим. Він важливий тим, що анонімний користувач або клієнтський фронтенд тепер може отримати цю статтю з публічного API.

Тому другий обов’язковий шар доказу — звернення до public endpoint. Найпряміший шлях — GET /api/public/articles/{slug}. Ми не зобов’язані тут перевіряти весь JSON-контракт. Для regression suite достатньо кількох ключових полів, які показують: «так, це та сама стаття, і вона справді доступна».

Хороша практика — додати в тест ще одну мініконтрольну точку: до approve стаття не має бути доступною публічно. Тоді тест перетворюється на доказ зміни спостережуваного результату.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void approve_makesArticleVisibleInPublicApi() throws Exception {
    // До approve стаття в IN_REVIEW не має бути видимою публічно
    mvc.perform(get("/api/public/articles/{slug}", "intro-to-spring"))
            .andExpect(status().isNotFound());

    // Дія: публікуємо
    approve(100L);

    // Після approve стаття має стати доступною анонімному читачеві
    mvc.perform(get("/api/public/articles/{slug}", "intro-to-spring"))
            .andExpect(status().isOk());
}

Тепер додамо пару змістових перевірок тіла відповіді. Зазвичай достатньо slug і title. Це не дублює JSON-contract tests, а підтверджує ідентичність результату.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void publishedArticle_hasExpectedSlugAndTitle() throws Exception {
    // Дія: публікуємо статтю
    approve(100L);

    // Перевірка: публічне API віддає саме ту статтю, яку ми очікуємо
    mvc.perform(get("/api/public/articles/{slug}", "intro-to-spring"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.slug").value("intro-to-spring")) // Перевіряємо ідентичність за slug
            .andExpect(jsonPath("$.title").value("Intro to Spring")); // І за заголовком (мінімально корисний набір)
}

Чому це сильна контрольна точка? Тому що вона одночасно ловить кілька класів регресій. Якщо admin approve почав змінювати статус, але public контролер і далі фільтрує за неправильним умовою, тест упаде. Якщо slug зламали і public читання тепер шукає не за slug, а за id, тест теж упаде. Якщо під час публікації стаття не зберігається так, як очікується, public endpoint також не знайде її.

І що особливо приємно: ця контрольна точка говорить мовою користувача. Вона не про внутрішності, а про реальну поведінку API.

7. Не роздуваємо regression-тест

Дорогий тест хочеться зробити «найнадійнішим», і мозок підказує просте рішення: перевірити все. Але перевірити все — означає включити в regression suite те, що вже давно й дешево захищене unit-, JSON- і MVC-slice тестами. У підсумку regression suite стає повільним, шумним і постійно ламається від несуттєвих змін.

Зафіксуймо інженерне правило: regression-тест має перевіряти унікальну і кросшарову поведінку. Для publish-flow унікальність — це перехід статусу плюс зовнішня видимість. Усе інше або вже покрито, або занадто детальне для такого рівня.

Невелика таблиця допомагає не збитися з курсу:

Що ви перевіряєте в регресійному тесті публікації Що це доводить Де НЕ треба робити це детально
POST approve повертає 200 endpoint живий, підключення працює не треба перевіряти всі response поля
status == PUBLISHED публікацію зафіксовано на рівні даних не треба перевіряти всю матрицю переходів
publishedAt != null публікацію зафіксовано в часі не треба звіряти точні таймстемпи без фіксованого часу
GET public by slug повертає 200 стаття стала публічно видимою не треба порівнювати весь JSON як «golden file»
slug/title збіглися публічно повернулася правильна стаття не треба дублювати JSON-contract tests

Якщо вам дуже хочеться перевірити більше, це бажання зазвичай сигналізує про дві речі. Або у вас немає дешевших тестів, і ви намагаєтеся «компенсувати» це дорогим. Або ви випадково перетворили regression-тест на «другий шар документації DTO». Обидва варіанти не надто вдалі.

8. Структура тесту: одна дія — один сенс

Коли suite росте, тести починають жити довше, ніж ваш поточний ентузіазм. Це означає, що через місяць або через пів року ви самі будете читати тест і думати: «А що він узагалі доводить?». Тому publish regression-тест варто писати так, ніби це коротка історія.

Тут добре працює принцип: «імʼя тесту — це заголовок новини». Не approve_publishesArticle2(), а щось, що містить перехід і спостережуваний результат.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("Approve → publish робить статтю видимою в public API")
@Test
void approve_publishes_andPublicCanReadBySlug() throws Exception {
    // Дія: публікуємо статтю (далі доводимо спостережуваний ефект)
    approve(100L);

    // Перевірка: публічне API тепер бачить статтю за slug
    mvc.perform(get("/api/public/articles/{slug}", "intro-to-spring"))
            .andExpect(status().isOk());
}

Якщо сценарій довший, допускаються маленькі helper-методи, які прибирають рутину, але не приховують кроки. Наприклад, такий helper часто робить тест візуально легшим:

import com.example.contenthub.entity.ArticleStatus;

import static org.assertj.core.api.Assertions.assertThat;

private void assertStatus(long articleId, ArticleStatus expected) {
    // Читаємо сутність із repository — це перевірка факту збереження, а не "стану в пам'яті"
    var article = articleRepository.findById(articleId).orElseThrow();
    assertThat(article.getStatus()).isEqualTo(expected);
}

І основний сценарій починає читатися майже як «специфікація»:

@Test
void approve_publishesArticle() throws Exception {
    // Підготовку вже виконано через @Sql: стаття перебуває в IN_REVIEW
    approve(100L); // Дія: виконуємо publish-flow
    assertStatus(100L, ArticleStatus.PUBLISHED); // Перевірка: публікація справді відбулася
}

Це хороший компроміс: менше шуму, більше сенсу.

9. Типові помилки під час тестування approve → publish

Помилка №1: починати publish-flow з повного create + submit + approve в одному тесті.
Такий тест виглядає героїчно, але на практиці він стає «монолітом усередині моноліту»: будь-який збій — у валідації, даних або на будь-якому з кроків — ламає весь сценарій, а причина падіння ховається в довгому простирадлі логів. Набагато професійніше — фіксувати вхідний стан IN_REVIEW і перевіряти publish-flow окремо, а create/submit тримати в іншому регресійному тесті.

Помилка №2: вважати publish-flow доведеним за одним status().isOk() на approve endpoint.
200 OK говорить лише про те, що запит оброблено без винятку. Він не гарантує, що статус змінився, що publishedAt заповнився і що public API тепер бачить статтю. Регресійний тест має доводити результат, а не факт «контролер відпрацював».

Помилка №3: повторно перевіряти в regression-тесті весь JSON відповіді public endpoint як «золотий знімок».
Це робить тест крихким: будь-який косметичний рефакторинг DTO, порядок полів або додавання нового поля ламає ваш «головний» regression suite. Повний JSON-контракт уже має бути захищений окремими JSON- і web-slice тестами. У regression-тесті залишайте 2–4 ключові поля, які доводять сенс: ідентичність статті та факт доступності.

Помилка №4: ховати стартовий стан у «розумному» helper-і, який незрозуміло що робить.
Якщо ваш тест починається з preparePublishScenario() і далі одразу approve(), читач тесту не розуміє, що саме підготовлено і чому. У regression suite стартовий стан — це частина документації. @Sql("/sql/article-in-review.sql") або короткий setup із явною назвою зазвичай читається краще і діагностується швидше.

Помилка №5: намагатися строго перевіряти точне значення publishedAt, не контролюючи час.
Іноді хочеться написати assertThat(article.getPublishedAt()).isEqualTo(Instant.now()) — і це гарантований спосіб отримати flaky-тест, який то проходить, то ні. Якщо ви не фіксуєте час через тестову конфігурацію, перевіряйте лише факт заповнення (isNotNull()). Точні таймстемпи мають сенс лише в детермінованому середовищі з контрольованим Clock.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ