JavaRush /Курси /Spring Test /Фікстури, DSL і назви тестів

Фікстури, DSL і назви тестів

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

1. Підтримуваність тестів

Коли проєкт маленький, тести пишуться «на натхненні»: створили об’єкт, замокали залежність, перевірили status().isOk() — і рухаємося далі. Але в реальному Spring Boot-застосунку (і в нас у ContentHub це добре видно) тестів стає десятки й сотні, а найдорожча операція — не написати новий тест, а зрозуміти старий, який упав у CI в п’ятницю ввечері.

Проблема зазвичай не в тому, що тест «складний». Проблема в тому, що він нечитабельний: дані розкидано по методу, магічні рядки повторюються, різні шари (DTO, entity, security, web) змішані в одну кашу, і будь-який рефакторинг перетворюється на гру «знайдіть 27 місць, де захардкожено slug». Тому ми й говоримо про три речі, які буквально рятують набір тестів: fixture factories (читабельні тестові дані), невеликий test DSL (читабельну дію) і naming conventions (передбачувану навігацію).

Щоб зафіксувати думку, корисно уявити «ціну» тесту не лише як час виконання, а й як час розуміння:

flowchart TD
  A["Тест упав"] --> B["Знайти тест"]
  B --> C["Зрозуміти сценарій"]
  C --> D["Зрозуміти дані"]
  D --> E["Зрозуміти шар (unit/web/data/integration)"]
  E --> F["Усунути причину"]

Якщо кроки C–E займають більше часу, ніж F, то test suite технічно існує, але як інженерний інструмент він перетворюється на декоративну рослину: красиво зелену, але майже безкорисну.

2. Фабрика фікстур: дані мовою домену

Fixture factory — це простий тестовий код, який створює тестові дані так, щоб вони читалися як сенс, а не як «ось тут у нас 12 полів конструктора, і ми сподіваємося, що порядок правильний». В ідеалі за назвою методу фікстури ви одразу розумієте стан: draftArticle(), publishedArticle(), validCreateRequest(), missingTitleRequest().

Дуже важливо, що fixture factory — не «універсальний генератор усього на світі», а акуратна бібліотечка для вашого test suite. Вона має бути детермінованою (жодних Random і «сьогоднішньої дати» без причини), уникати глобального змінюваного стану й бути розділеною за шарами. І так, це той рідкісний випадок, коли копіпаста іноді краща, ніж один «суперметод» на 200 параметрів.

Порівняймо три підходи до тестових даних на одній картинці — щоб у мозку з’явився внутрішній «детектор поганих фікстур»:

Підхід Як виглядає Що хорошого Де зазвичай боляче
Inline-дані прямо в тесті new CreateArticleRequest("...", "...", ...) Швидко почати Повтори, магічні рядки, важко змінювати
Builder (test data builder) CreateArticleRequestBuilder .aRequest().withTitle(...) Гнучкість, читабельність Легко піти в «мініфреймворк»
Fixture factory (Object Mother / factory) ArticleRequestFixtures.valid() Дуже читабельно, мало шуму Потрібна дисципліна, щоб не перетворити на звалище

У нашому курсі ми частіше тримаємося за fixture factory, тому що вона простіша для junior-рівня: менше абстракцій, більше прямого сенсу.

Мініприклад для ContentHub: фікстура для request DTO на web-границі.

import com.acme.contenthub.api.dto.request.CreateArticleRequest;

public final class ArticleRequestFixtures {

    public static CreateArticleRequest valid() {
        // Важливо: значення фіксовані й читабельні — тоді тест стає «мініісторією», а не набором випадкових рядків.
        // Якщо DTO зміниться, ви виправляєте одне місце, а не 15 тестів.
        return new CreateArticleRequest("Intro to tests", "Short summary", "Body", "JAVA");
    }
}

Сама по собі ця штука виглядає майже смішно — «заради чого файл?». Але щойно ви використовуєте це в 15 MVC-тестах, ви раптом розумієте, що файл був вартий своїх 30 секунд. І ви ще сильніше розумієте це, коли змінюється структура DTO: замість «виправляємо 15 тестів» ви виправляєте одне місце.

3. Фікстури за шарами: DTO, Entity, Flow

Найчастіша причина деградації тестового набору — фікстури, які починають просочуватися між шарами. Сьогодні ви берете entity Article у @WebMvcTest, бо «так простіше». Завтра ви починаєте серіалізувати entity в JSON, бо «це ж працює». Післязавтра ви плачете, бо змінили mapping або додали ліниве зв’язування — і раптом ваш controller test падає через JPA-нюанс. Тест же був про HTTP-контракт, але страждає так, ніби він data-layer.

Щоб не зробити suite «універсально стражденним», фікстури зазвичай ділять щонайменше на три сімейства: для web-layer DTO, для data-layer entities і для integration-сценаріїв (де ви створюєте стан через API/репозиторій/SQL). Це не бюрократія, а спосіб тримати межі відповідальності.

Уявімо це як «правило трьох коробок» — і нехай кожна коробка лишається на своєму місці:

flowchart TD
  A["@WebMvcTest"] --> B["DTO-фікстури (запит/відповідь)"]
  C["@DataJpaTest"] --> D["Entity-фікстури (Article/Category/Attachment)"]
  E["@SpringBootTest"] --> F["Flow-фікстури (draft → submit → approve)"]

Мініприклад: окремо фікстури для сутностей. Так, це може бути інший пакет, інша назва й інший стиль — і це нормально.

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

public final class ArticleEntityFixtures {

    public static Article draft() {
        // Entity-фікстури мають використовуватися в data-тестах (наприклад, @DataJpaTest),
        // щоб не тягнути JPA-деталі в MVC-шар.
        return new Article("Draft title", "draft-title", ArticleStatus.DRAFT, "alice");
    }
}

Тут важливо не те, що конструктор «реальний» (у вас він може бути іншим), а сама думка: entity-фабрика живе там, де живуть data-тести, і не тягнеться в MVC-шар як універсальна паличка-виручалочка.

4. Детерміновані фікстури: час, slug, id

На ранніх етапах здається, що Instant.now() у тесті — безпечна дрібниця. Потім ви раптом отримуєте тест, який «іноді падає на межі доби», і з’ясовується, що це не містика, а банальна залежність від часу. Те саме з UUID.randomUUID() і випадковими slug: тест стає важко читати, очима неможливо зрозуміти, що саме очікується, а відлагодження перетворюється на полювання на невидимого гремліна.

Правило просте: якщо значення важливе для сенсу тесту, воно має бути фіксованим і читабельним. Якщо значення неважливе — краще взагалі його не перевіряти. Фікстури якраз і дають місце, де такі фіксовані значення можна зберігати централізовано.

Наприклад, фіксований час для тестів. Навіть якщо в production ви використовуєте Clock, у тестовому коді зручно мати один «канонічний момент».

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

public final class TestClocks {

    public static Clock fixedUtc() {
        // Фіксуємо "поточний час" для стабільних тестів.
        // Важливо: жодних now() — інакше отримаєте падіння «іноді», особливо на межі доби та в різних часових поясах.
        return Clock.fixed(Instant.parse("2026-03-21T10:15:30Z"), ZoneOffset.UTC);
    }
}

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

Та сама ідея зі slug і користувацькими іменами. У ContentHub у нас є ролі й користувачі (EDITOR, ADMIN, alice, bob). Якщо ви генеруватимете імена випадково, ви вб’єте читабельність. Набагато приємніше, коли тестові дані виглядають як мініісторія.

public final class TestUsers {

    // Імена «героїв» тестів: читабельно в логах і зрозуміло в правилах доступу.
    public static final String ALICE = "alice";
    public static final String BOB = "bob";

    private TestUsers() {
    }
}

Чесно: так, це «константи в тестах». Але вони перетворюють логіку правил доступу на читабельний сюжет, а не на user_84f1.

5. Test DSL: менше шуму, більше дії

Коли тестів стає багато, повторюється один і той самий шум: побудувати запит, поставити заголовки, закинути body, перевірити статус, іноді розпарсити відповідь. І тут з’являється спокуса написати «універсальний DSL», який зробить усе за вас. Це небезпечний момент. Хороший test DSL — тонкий. Він економить повторення, але не ховає межу шару. Поганий DSL перетворює тест на загадку: «хто тут що викликав і що взагалі перевіряємо?».

Практичне правило: DSL має описувати дію (act), а не затягувати в себе весь тест (arrange+assert теж). Дія в нас часто одна: «викликати endpoint» або «зберегти сутність». А от перевірки краще тримати поруч із тестом або в невеликих assertion helpers (про них нижче).

Тут це свідомо інший підхід, ніж у прямому assertThat(mvc.get()... прикладі: helper робить лише дію через .exchange(), а перевірка залишається в тесті.

Мініприклад тонкого DSL для MVC-тестів на MockMvcTester. Зверніть увагу: він не робить assert-ів, він лише виконує запит і повертає результат.

import org.springframework.test.web.servlet.assertj.MockMvcTester;

class EditorApi {
    private final MockMvcTester mvc;

    EditorApi(MockMvcTester mvc) { this.mvc = mvc; }

    MockMvcTester.Result createDraft(Object body) {
        // DSL робить лише дію: виконує запит.
        // Важливо: не ховаємо assert-и сюди, щоб тест залишався читабельним «ззовні».
        return mvc.post().uri("/api/editor/articles").body(body).exchange();
    }
}

Тепер тест стає коротшим, але все ще прозорим — ви бачите URL і бачите, що надсилаєте body.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;

class EditorArticleControllerWebMvcTest {

    private EditorApi editor; // припустімо, ініціалізували в setup

    @Test
    void shouldCreateDraft() {
        // Arrange сховано у фікстурі: читабельні й детерміновані дані.
        var res = editor.createDraft(ArticleRequestFixtures.valid());

        // Assert поруч із тестом: за проваленою перевіркою одразу видно очікування.
        assertThat(res).hasStatusCreated();
    }
}

Це й є «маленький DSL»: він робить тест коротшим і менш шумним, але не перетворює його на магію.

6. Naming conventions: навігація в suite

Коли в проєкті десятки тестів, навігація стає проблемою рівня IDE: ви шукаєте «тести про public API», а знаходите ControllerTest1, PublicTest, ArticleTestNew2. Це смішно рівно до того моменту, доки ви не витрачаєте 20 хвилин на пошук потрібного файлу.

Naming conventions розв’язують це простим способом: за назвою класу (і часто за пакетом) одразу видно шар і відповідальність. У нас у ContentHub це особливо важливо, бо шарів багато: unit, json, web slice, data slice, integration, security, outbound, async, containers, docs. І коли назва витримана, мозку легше: він не «вгадує», а розпізнає патерн.

Одна з зручних моделей — суфікси за типом тесту (вона вже зафіксована в проєктній документації курсу):

Суфікс/назва Що це означає Приклад
Test без анотацій Spring unit PublicationPolicyTest
JsonTest JSON-contract шар ApiProblemJsonTest
WebMvcTest MVC slice PublicArticleControllerWebMvcTest
DataJpaTest data slice ArticleRepositoryDataJpaTest
IntegrationTest повний контекст / live server ArticlePublicationFlowIntegrationTest
RestDocsTest REST Docs поверх MVC PublicArticleRestDocsTest

Вам не потрібно робити «ідеальну класифікацію всього світу». Вам потрібно, щоб, відкривши тестовий клас, ви за 2 секунди могли відповісти на три питання: який шар, що перевіряє, чим живиться (моки/БД/сервер).

Назви методів теж важливі. Ми вже говорили про @DisplayName і AAA, але тут додамо маленьке правило: назва тесту має звучати як очікування поведінки, а не як «клікнути кнопку». У бекенді кнопок немає, тому краще одразу писати про контракт або правило.

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

class PublicArticleControllerWebMvcTest {

    @Test
    @DisplayName("GET /api/public/articles повертає лише PUBLISHED статті")
    void shouldReturnOnlyPublishedArticles() {
        // Тут назва — частина документації: що саме гарантує контракт.
        // Ідея: лише з одного DisplayName уже зрозуміло, чому цей тест існує.
        // ...
    }
}

Так, це трохи довше, ніж test1(). Але test1() ви будете проклинати. А цей рядок — ні.

7. Assertion helpers: повторне використання перевірок

Коли у вас з’являється стабільний error contract (ApiProblem зі status, errorCode, violations), рука тягнеться винести перевірки в helper. Це хороша ідея, якщо helper не перетворюється на «великий метод, який перевіряє все підряд і незрозуміло що саме». Хороший helper групує повторювані низькорівневі перевірки, залишаючи сенс сценарію в тесті.

Наприклад, у вас часто повторюється «access denied», і ви хочете перевіряти мінімум: статус і код помилки. Винесемо це в helper для DTO, а не для MVC-результату — так він буде застосовним у різних шарах.

import com.acme.contenthub.api.error.ApiProblem;

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

public final class ApiProblemAssertions {

    public static void assertAccessDenied(ApiProblem p) {
        // Тримаємо helper маленьким: лише те, що справді повторюється.
        // Сенс сценарію («чому саме access denied») залишається в тесті.
        assertThat(p.status()).isEqualTo(403);
        assertThat(p.errorCode()).isEqualTo("ACCESS_DENIED");
    }
}

А в тесті ви залишаєте контекст: де й чому ви очікуєте цю помилку. Helper не повинен замінювати сюжет.

Важливе обмеження: якщо helper починає сам створювати дані, робити HTTP-виклик і ще й перевіряти JSON, він стирає межі шару й перетворюється на мікросервіс усередині тестів. Це майже завжди поганий знак. Невелике дублювання в тестах іноді корисніше, ніж «універсальний комбайн».

8. Типові помилки: фікстури, DSL і назви

Помилка №1: fixture factory перетворюється на «звалище всього».
Зазвичай це починається невинно: ви додали ArticleFixtures.valid(), потім ArticleFixtures.validWithLongTitle(), потім ArticleFixtures.forMvc(), ArticleFixtures.forJpa(), ArticleFixtures.forIntegration() — і все це в одному класі. Через місяць ніхто не розуміє, чим відрізняються методи, і люди знову починають писати inline-дані. Лікується це простим розділенням за шарами: DTO-фікстури окремо, entity-фікстури окремо, flow-фікстури окремо.

Помилка №2: тестові дані починають жити випадковим життям.
Це класика Instant.now(), UUID.randomUUID() і «нехай slug буде title + "-" + random». Іноді це спрацьовує, але читабельність гине миттєво: ви не можете очима зрозуміти, що очікуєте, і будь-які відлагоджувальні повідомлення стають беззмістовними. Детерміновані значення не «роблять тести нудними», вони роблять їх інженерними.

Помилка №3: DSL ховає HTTP-контракт або стан БД.
Якщо ваш DSL-метод називається publishArticle() і всередині робить три HTTP-запити, підміняє користувача, ще й перевіряє JSON, тест перетворюється на один рядок, але разом із ним зникає розуміння того, що саме перевіряється. Особливо небезпечно це в курсі про тестування: ви ніби «скоротили код», а насправді сховали від себе саму механіку шару.

Помилка №4: naming conventions «приблизно такі», але скрізь різні.
Один тест у вас ArticleControllerTest, інший PublicArticleControllerWebMvcTest, третій publicArticlesTest. У підсумку пошук і навігація в IDE не допомагають, і будь-який новий учасник команди знову витрачає час на орієнтацію. Краще вибрати нудний, передбачуваний патерн і дотримуватися його, ніж щоразу «називати як відчувається».

Помилка №5: assertion helpers витісняють сенс із тесту.
Якщо в тесті лишається тільки assertEverythingIsOk(res), а всередині helper-а 40 перевірок, тест перестає бути документацією. Особливо неприємно, коли падає одна з 40 перевірок, і за стектрейсом незрозуміло, який бізнес-сенс узагалі порушено. Helper має бути маленьким і тематичним: «перевірити errorCode», «перевірити базові поля пагінації», а не «перевірити всесвіт».

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