JavaRush /Курси /Spring Test /Дисципліна запуску test suite

Дисципліна запуску test suite

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

1. Запуск тестів як частина якості

Коли проєкт маленький, здається, що запуск тестів — це просто «натиснули зелену кнопку в IDE, отримали зелену галочку й пішли пити чай». Але коли test suite стає багаторівневим, а тести починають генерувати snippets для REST Docs, раптово зʼясовується, що запуск — це вже інженерна частина системи. У тестів зʼявляється «побічний продукт», і він має бути стабільним, відтворюваним та не залежати від настрою вашої IDE.

У ContentHub до цього моменту тести живуть у різних режимах: unit без Spring, slices (@WebMvcTest, @JsonTest, @DataJpaTest), кілька повних інтеграційних тестів і пізня контейнерна підмножина. У кожного типу є своя ціна запуску, свої таймаути та свої джерела нестабільності. І якщо ми не домовимося про базові правила виконання, то отримаємо класичну трагедію: «в CI впало», «у мене не падає», «ну давайте додамо Thread.sleep(5000)» (спойлер: так народжується флак, а не якість).

Дисципліна запуску — це не про процес заради процесу, а про три практичні речі: єдині дефолтні правила JUnit, коректне поводження з вихідними файлами (snippets) і повагу до того, що Spring-контекст кешується, а це економить нам час.

Файл junit-platform.properties

junit-platform.properties — це спосіб задати JUnit Platform загальні параметри виконання тестів. За змістом це схоже на «глобальні налаштування тестового рушія»: не для одного класу, не для одного методу, а для всього набору тестів, який запускається на платформі.

Важливо не переплутати два рівні. JUnit Platform — це «основа», яка запускає тестові рушії, наприклад Jupiter. А JUnit Jupiter — це конкретний рушій, який розуміє @Test, @Nested, @Timeout та інші анотації. У junit-platform.properties ми зазвичай пишемо параметри Jupiter, тому що в ContentHub ми використовуємо JUnit Jupiter 6.x як основний рушій, але технічно файл читається платформою.

Де його шукати? Найпрактичніший варіант для звичайного Gradle-проєкту — покласти файл сюди:

src/test/resources/junit-platform.properties

Чому саме туди? Тому що src/test/resources потрапляє в test classpath, а JUnit Platform шукає junit-platform.properties саме в корені classpath. Тобто ви кладете файл туди, де Gradle гарантовано доставить його до test runtime.

Якщо ви колись забудете, де цей файл, можна застосувати давній ритуал розробника: «пошук у проєкті». Він працює навіть без Spring.

3. Налаштування в junit-platform.properties

На цьому кроці легко зробити дві крайнощі. Перша — не винести нічого й потім копіювати одні й ті самі анотації та налаштування в десятки тестів. Друга — перетворити junit-platform.properties на «магічну книгу заклинань», де виставлено 30 параметрів, і ніхто не знає, чому suite поводиться так дивно. Ми триматимемося середини: винесемо лише те, що справді дає передбачуваність, і водночас не ховає сенс тестів.

Для ContentHub зазвичай добре працюють три види налаштувань: дефолтний таймаут, щоб тести не висіли вічно; поведінка таймауту під час дебага, щоб ви могли спокійно поставити breakpoint; і дефолтний lifecycle тестових інстансів, щоб не отримати випадковий спільний стан.

Приклад мінімального файлу:

# src/test/resources/junit-platform.properties

# Загальний таймаут для тестів, де @Timeout не задано явно
junit.jupiter.execution.timeout.default = 10 s

# Під час запуску в debug таймаути вимикаються, щоб breakpoint не вважався "зависанням"
junit.jupiter.execution.timeout.mode = disabled_on_debug

# Новий екземпляр тестового класу на кожен @Test-метод — менше шансів на витік стану
junit.jupiter.testinstance.lifecycle.default = per_method

Розберімо ці рядки людською мовою, без «ось вам 15 посилань на документацію».

Таймаут за замовчуванням: захист від «вічного тесту»

junit.jupiter.execution.timeout.default = 10 s каже: якщо тест сам не вказав @Timeout, то JUnit застосує загальний таймаут. Ідея проста: тест має або пройти, або впасти. А «зависнути й чекати вічність» — не третій хороший варіант, а технічний борг у режимі «накопичуємо відсотки».

Чому 10 секунд, а не 2? Тому що наш набір тестів не складається лише з мікроскопічних unit-тестів. У нас є Spring-based тести, а подекуди й очікування, наприклад Awaitility у попередніх днях. Дефолтний таймаут має бути достатньо великим, щоб не вбивати чесні тести, і достатньо малим, щоб ловити зависання. 10 секунд — добра навчальна відправна точка. У реальній команді це число обирають емпірично, а не за місячним календарем.

Таймаути під час налагодження: щоб breakpoint не вважався «зависанням»

junit.jupiter.execution.timeout.mode = disabled_on_debug — це налаштування з серії «вас одного разу вкусить, і ви більше не забудете». Без нього ви ставите breakpoint, відходите подумати, повертаєтесь — а тест уже впав по таймауту, тому що JUnit чесно вважає: «друже, ти обіцяв уміститися в час».

Режим disabled_on_debug дає змогу вимикати таймаути, коли ви запускаєте тест у debug. Це робить налагодження нормальним, а не гонкою «встигни подивитися змінні за 2 секунди».

Lifecycle тестового класу: менше сюрпризів із спільним станом

junit.jupiter.testinstance.lifecycle.default = per_method означає: на кожен тестовий метод створюється новий екземпляр тестового класу. Для новачка це майже завжди найбезпечніший варіант, тому що стан тесту не «просочується» між методами через поля.

Так, per_class іноді зручніший, особливо коли потрібен @BeforeAll без static. Але якщо ви не впевнені, що ваш тестовий клас не зберігає стан, краще тримати базовий дефолт per_method. Нехай тести будуть трохи «нудніші», зате передбачуваніші. Нудні тести — це комплімент.

4. Локальні перевизначення правил

Глобальні налаштування — це «базова гравітація» набору тестів. Але іноді конкретний тест обʼєктивно потребує інших умов. У цьому місці важливо не починати війну «анотації проти properties». Нормальна інженерна модель така: junit-platform.properties задає розумний дефолт, а тест за потреби явно заявляє виняток.

Найтиповіший кейс — локальний таймаут. Наприклад, у вас є тест, який перевіряє, що операція не має бути надто повільною, і це частина контракту, а не просто «тому що я так хочу». Тоді @Timeout на конкретному тесті — саме те, що потрібно.

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

import java.util.concurrent.TimeUnit;

class SlugServiceTest {

    @Test
    @Timeout(value = 200, unit = TimeUnit.MILLISECONDS) // локальне, суворіше обмеження саме для цього тесту
    void shouldGenerateSlugFastEnough() {
        // Тут був би виклик slugService.generate(...)
        // Ідея: тест має впасти, якщо метод раптом стане "гальмом".
        //
        // Важливо: цей таймаут — частина контракту швидкості для конкретного сценарію,
        // а не спроба "прискорити" весь набір тестів глобально.
    }
}

Інший частий кейс — lifecycle тестового інстанса. Якщо ви свідомо хочете per_class (наприклад, тому що expensive setup і ви впевнені, що не зберігаєте mutable state між тестами), це робиться локально через @TestInstance.

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

@TestInstance(TestInstance.Lifecycle.PER_CLASS) // один інстанс тестового класу на всі методи
class ContentHubApplicationSmokeTest {

    @Test
    void contextShouldStart() {
        // Smoke test: контекст стартує, біни створено, конфігурацію прочитано.
        //
        // Важливо: при PER_CLASS особливо легко випадково почати ділитися станом через поля.
        // Якщо так зробили — стежте, щоб поля не ставали "прихованим сховищем" між тестами.
    }
}

Сенс такий: глобально ми граємо в безпечну гру, локально — беремо відповідальність і робимо виняток. Не навпаки.

5. generated-snippets як артефакт збірки

До REST Docs тести поводилися як акуратний суддя: «винен/невинний», «зелений/червоний». З REST Docs тести стають ще й «міні-заводом», який виробляє файли — snippets. І це автоматично робить питання запуску серйознішим: файли мають зʼявлятися там, де їх чекають, і бути актуальними.

Якщо ви запускаєте REST Docs тест, наприклад PublicArticleRestDocsTest, і в ньому є:

.apply(document("public-article-list"));

то в build-директорії Gradle зʼявиться папка на кшталт:

build/generated-snippets/public-article-list/

А всередині — файли snippets. Їхній точний набір залежить від того, що ви документували. Навіть якщо ви робите Markdown snippets, це все одно файловий output. І тут починаються дуже практичні питання:

Якщо ви перейменували snippet, старий каталог залишиться? Так, цілком може залишитися.

Якщо ви не очистили build/, чи можете випадково зібрати документацію, де частина snippets стара? Теж так.

Якщо ви запустили тести частково, наприклад лише один клас, чи отримаєте «повний набір snippets» для документації? Ні, ви отримаєте лише те, що запускали.

Звідси важлива думка: generated-snippets — це не «сміття після тестів», а результат виконання, який має бути керованим. Ми не комітимо його в git (зазвичай), але ми зобовʼязані ставитися до нього як до справжнього артефакту збірки.

Щоб побачити весь ланцюжок, зручно уявити його так:

flowchart TD
    A["./gradlew test"] --> B["JUnit Platform"]
    B --> C["Spring TestContext"]
    C --> D["MockMvcTester + document(...)"]
    D --> E["build/generated-snippets"]

У цьому графі немає магії: якщо тест не запустився — snippets не зʼявилися. Якщо тест упав — snippets, найімовірніше, теж не зʼявилися, або зʼявилися частково, що ще веселіше. Тому дисципліна запуску — це буквально умова того, що REST Docs взагалі працює так, як задумано.

6. Gradle і generated-snippets

Коли Gradle не знає, що якась директорія є output-ом задачі, він ставиться до неї як до побічного ефекту. Побічні ефекти — річ підступна: вони погано кешуються, погано відстежуються і погано пояснюються в build-логіці.

Нам потрібно, щоб Gradle розумів: після test очікується директорія зі snippets. Тоді і локальні сценарії, і CI, і просто розуміння структури проєкту стають простішими.

Мінімальне налаштування має такий вигляд:

import org.gradle.api.tasks.testing.Test

tasks.named<Test>("test") {
    useJUnitPlatform() // явно фіксуємо, що тести запускаються на JUnit Platform (Jupiter 6 у межах курсу)

    // Кажемо Gradle: після виконання test очікуємо цей каталог як output (важливо для передбачуваності та кешування)
    outputs.dir(layout.buildDirectory.dir("generated-snippets"))
}

Тут три думки. По-перше, useJUnitPlatform() робить намір явним: ми запускаємо тести через JUnit Platform (у нашому курсі — JUnit Jupiter 6). По-друге, outputs.dir(...) каже: «ця папка — результат виконання тестів». По-третє, шлях береться через layout.buildDirectory, щоб не хардкодити build/ руками і не сперечатися з Gradle про те, де в нього buildDir.

Є ще один практичний нюанс: іноді корисно перед запуском тестів очистити snippets, щоб не жити зі старими файлами. Це особливо приємно в документації, тому що «старий snippet» виглядає як актуальний, доки ви не помітите, що в API вже інше поле.

Мініверсія такого очищення:

import org.gradle.api.tasks.testing.Test

tasks.named<Test>("test") {
    doFirst {
        // Чесне прибирання: перед прогоном тестів видаляємо старі snippets, щоб не підхопити застарілі файли
        delete(layout.buildDirectory.dir("generated-snippets"))
    }
}

Це не обовʼязкова магія, а просто чесне прибирання перед роботою. Як на кухні: якщо минулого разу ви робили борщ, а тепер хочете пекти кекс, краще все ж помити каструлю. І так, я розумію, що розробники не люблять мити каструлі. Але тести люблять.

7. Кеш контексту Spring і @DirtiesContext

Spring TestContext framework не створює ApplicationContext заново для кожного тесту, тому що це було б надто повільно. Замість цього він кешує контексти й перевикористовує їх між тестовими класами, якщо конфігурація збігається. Це одна з причин, чому slice-тести і навіть частина full-context тестів можуть працювати адекватно за швидкістю.

Але в нас є інструмент, який може цей кеш «спалити»: @DirtiesContext. Ця анотація каже Spring: «після тесту контекст вважається забрудненим, не перевикористовуй його». І Spring слухняно викидає контекст із кешу. Наступний тест, якому потрібен такий самий контекст, буде будувати його заново.

Виглядає безневинно:

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;

@SpringBootTest // повний контекст: дорого, але іноді необхідно для інтеграції
@DirtiesContext // після тесту контекст вважається "брудним" і буде створений заново в наступних тестах
class ArticlePublicationFlowIntegrationTest {
    // Важливо: @DirtiesContext — це не "універсальний фікс", а свідома ціна за час набору тестів.
}

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

Щоб закріпити уявну модель, можна уявити кеш так:

flowchart TD
    A["Клас тесту №1"] --> K["Ключ контексту"]
    B["Клас тесту №2"] --> K
    K --> Cache[("Кеш контекстів Spring")]
    Cache --> Ctx["ApplicationContext повторно використовується"]

    D["@DirtiesContext"] --> Cache
    D --> X["Запис кешу вилучено"]

@DirtiesContext — це справді корисний механізм. Але він має бути винятком, а не звичкою. За відчуттями, це як кнопка «перезапустити ноутбук», коли браузер гальмує. Іноді треба. Але якщо ви робите так кожні 5 хвилин, проблема, найімовірніше, не в браузері.

Частіше за @DirtiesContext краще зрозуміти, чим саме тест «псує» стан. Іноді це статичні кеші. Іноді це запис файлів у спільний каталог. Іноді це глобальні System.setProperty(...). Іноді це спільний змінний фікстурний стан. Ці речі краще лікувати локально: скидати кеш після тесту, використовувати тимчасові директорії, не чіпати глобальні налаштування процесу без відкоту. Так, це потребує трохи більше дисципліни. Але зате набір тестів залишається швидким.

8. Сценарій стабільного запуску docs-тестів

У ContentHub REST Docs зазвичай привʼязують до публічних endpoint-ів, тому що це саме та поверхня, якою користуються зовнішні клієнти. Отже, нам потрібно досягти трьох практичних властивостей: тести завжди запускаються однаково, snippets завжди зʼявляються в очікуваному місці, контекст не створюється заново без причини.

І для такого сценарію тримаємося того ж прямого ходу, що і в інших REST Docs прикладах: request → assertions → document(...).

Нижче — «скелет» REST Docs тесту, який добре вписується в цю філософію. Він короткий, тестує HTTP-контракт і документує поверх assertions:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;

@WebMvcTest(PublicArticleController.class) // MVC-slice: швидше й детермінованіше, ніж піднімати сервер
@AutoConfigureRestDocs // вмикає інтеграцію Spring REST Docs зі Spring MVC тестами
class PublicArticleRestDocsTest {

    @Autowired
    MockMvcTester mvc; // тестовий HTTP-клієнт поверх MockMvc

    @Test
    void shouldDocumentPublicArticleList() {
        // Явно фіксуємо контракт запиту: шлях і query-параметри — частина документації
        assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
            .hasStatusOk() // мінімальна перевірка: endpoint відповідає 200 OK
            .apply(document("public-article-list")); // генерація snippets у build/generated-snippets/...
    }
}

Що тут важливо в контексті дисципліни запуску? Тест використовує MVC slice, а не RANDOM_PORT сервер, тому він швидкий і детермінований. Він не потребує @DirtiesContext, тому що ми не змінюємо контекст. Він генерує snippets у стандартне місце, і Gradle знає, що це output тестів. А junit-platform.properties гарантує, що тест не висітиме вічно, і водночас не падатиме через таймаут, коли ви дебажите.

Саме так невеликі налаштування перетворюються на системну якість: ви не «лагодите кожен тест», а робите так, щоб уся екосистема була передбачуваною.

9. Типові помилки: запуск тестів і snippets

Помилка №1: перетворювати junit-platform.properties на «таємне місце сили» з десятками параметрів.
Зазвичай це закінчується тим, що набір тестів поводиться дивно, а ніхто не памʼятає, хто поставив parallel.enabled=true і чому тепер половина тестів падає залежно від фази місяця. Гарний файл налаштувань маленький: таймаут, режим дебагу, lifecycle. Усе інше додається лише тоді, коли ви можете пояснити, яку проблему вирішуєте.

Помилка №2: ставити занадто агресивний глобальний таймаут і потім лікувати падіння збільшенням «про всяк випадок».
Якщо ви поставили, наприклад, 1 секунду «для всіх тестів», то частина інтеграційних або async-перевірок почне падати не тому, що код поганий, а тому, що ви самі створили неможливі умови. А потім починається «давайте зробимо 60 секунд», і таймаут перестає виконувати роль страховки. Обирайте дефолт, який реалістичний для всього набору тестів, а точкові вимоги швидкості фіксуйте @Timeout там, де це важливо.

Помилка №3: не вважати build/generated-snippets результатом виконання тестів.
Коли snippets сприймаються як сміття, ніхто не стежить за їхньою актуальністю. У результаті документація може зібратися зі старих файлів, а ви будете впевнені, що вона «з тестів, отже завжди правильна». Snippets — це артефакт. Він має лежати в зрозумілому місці, очищатися за потреби й зʼявлятися передбачувано під час запуску тестів.

Помилка №4: зловживати @DirtiesContext як універсальною пігулкою від нестабільності.
@DirtiesContext іноді справді потрібен, але в більшості випадків він просто ховає проблему дизайну тесту або production-коду. І майже завжди він різко збільшує час набору тестів. Якщо тест залежить від брудного глобального стану, краще знайти джерело цього бруду й прибрати його: скинути кеш, не використовувати спільний каталог для файлів, не залишати статичні singleton-обʼєкти в невідомому стані.

Помилка №5: змішувати «як зручно в IDE» і «як реально працює збірка».
Запускати один тест з IDE — нормально. Але коли ви спираєтесь на артефакти на кшталт snippets, дедалі частіше треба запускати набір тестів так, як він запускатиметься в реальній збірці: через Gradle. Інакше ви отримуєте ситуацію, коли «у мене docs не зʼявилися», хоча ви просто не запускали потрібні тести або запускали їх в іншому контексті.

1
Задача
Spring Test, 26 рівень, 2 лекція
Недоступна
Централізовані налаштування JUnit Platform
Централізовані налаштування JUnit Platform
1
Задача
Spring Test, 26 рівень, 2 лекція
Недоступна
generated-snippets як звичайний output Gradle
generated-snippets як звичайний output Gradle
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ