JavaRush /Курсы /Spring Boot /JUnit и скелет теста в Spring Boot

JUnit и скелет теста в Spring Boot

Spring Boot
26 уровень , 1 лекция
Открыта

1. spring-boot-starter-test: тестовый набор

Страховка старта сама по себе не появляется: проекту нужен тестовый каркас, который умеет запускать тесты и, когда нужно, поднимать Spring-контекст. В тестировании новичка обычно подстерегают две крайности: либо «тесты — это страшно, не трогаю», либо «сейчас подключу десять библиотек, и они сами всё протестируют». Spring Boot (как всегда) предлагает путь спокойнее: берём один согласованный starter и получаем рабочий базовый стек. Это важно не потому, что “так модно”, а потому что версиями и совместимостью за вас уже аккуратно присмотрели.

Если упростить до человеческого: spring-boot-starter-test — это курируемый набор тестовых библиотек, который Boot подбирает так, чтобы они дружили по версиям и работали вместе. Вы подключаете один starter, а не вручную “дженгу” из зависимостей, где один неверный кубик — и весь вечер вы читаете NoSuchMethodError.

В build.gradle.kts это выглядит очень приземлённо:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // Важно: эта зависимость нужна только для тестов и не попадёт в продовый runtime-classpath
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Обратите внимание на слово testImplementation. Оно не просто «другое название implementation», а довольно важный смысл: эта зависимость нужна только для тестов и не должна становиться частью основного runtime-classpath приложения. То есть ваш прод-артефакт не тащит за собой тестовые штуки (и не превращается в «банку с сюрпризом»).

Что именно приносит spring-boot-starter-test? На уровне этой лекции нам достаточно понимать общую картину: он даёт JUnit 6 (Jupiter API), базовые assertion-механизмы и интеграцию со Spring-тестированием (когда вам нужно поднять контекст). Остальные библиотеки вы можете воспринимать как «есть в коробке, но сегодня не обязаны знать наизусть».

Вот “карта коробки” в виде таблицы (без попытки учить всё сразу):

Что приходит в составе starter’а Зачем это в обычной жизни
JUnit 6 (Jupiter API) Запускает тесты, понимает @Test, показывает отчёты.
Spring Test / Spring Boot Test Позволяет поднимать Spring/Boot-контекст в тестах, когда это действительно нужно.
AssertJ (обычно тоже есть) Более выразительные проверки, чем базовые JUnit assertions (можно жить без него на старте).
Mockito (обычно тоже есть) Заглушки/моки, когда вы хотите изолировать часть зависимостей (в этой лекции не углубляемся).
Немного поддержки JSON-тестов Полезно для web-слоя, но сегодня мы держим фокус на базовом каркасе.

Главная мысль: starter нужен не чтобы вы стали «человеком, который знает 40 тестовых библиотек», а чтобы вы могли начать писать понятные тесты без боли с зависимостями.

2. Где живут тесты: src/test/java

Когда вы впервые видите src/test/java, очень хочется относиться к нему как к “папке для всякого”, куда можно скинуть что угодно. Но в Gradle (и вообще в Java-мире) это не просто папка, а отдельный test source set. У него свой classpath, свои зависимости и свои правила сборки, а значит — свой смысл. Это разделение помогает держать проект чистым: прод-код не заражается тестовыми инструментами.

Структура проекта по source set’ам обычно выглядит так:

src/
├── main/
│   ├── java/          # боевой код приложения
│   └── resources/     # application.yaml, static/, и т.д.
└── test/
    ├── java/          # тестовый код
    └── resources/     # тестовые ресурсы (если нужны)

Идея простая: всё, что лежит в src/main/java, — это то, из чего собирается ваше приложение. Всё, что лежит в src/test/java, — это то, что помогает проверить приложение, но не является частью “продукта”.

Очень практичный нюанс, который экономит нервы: тесты обычно повторяют структуру пакетов боевого кода. Не потому что «так надо по ГОСТу», а потому что тогда вам легче ориентироваться. Если доменные модели лежат в com.example.catalogservice.catalog.domain, то тесты этих моделей логично положить в такой же пакет, только уже внутри src/test/java.

Ещё один важный момент: тесты компилируются и запускаются отдельной задачей Gradle. Как правило, вы запускаете:

./gradlew test

И внутри этого процесса Gradle компилирует тесты, поднимает тестовый runtime, запускает JUnit Jupiter и собирает отчёт. Если вы любите схемы (а мозг любит схемы), то это можно представить так:

flowchart TD
    A["./gradlew test"] --> B["Компиляция src/main и src/test"]
    B --> C["JUnit 6 запускает @Test методы"]
    C --> D["assertions (ваши проверки)"]
    D -->|OK| E["Зелёный тест + отчёт"]
    D -->|Fail/Exception| F["Красный тест + отчёт"]

В итоге src/test/java — это не «кладбище кусков кода», а встроенный механизм качества, который живёт рядом с приложением и регулярно напоминает: “ты точно уверен, что ничего не сломал?”

3. JUnit Jupiter и @Test

Если Spring Boot — это “платформа для приложения”, то JUnit — это “платформа для запуска тестов”. Причём хорошая новость в том, что базовая модель JUnit очень проста: у вас есть тестовый класс, в нём тестовый метод, и он помечен @Test. Всё. Никакого public static void main, никаких специальных “раннеров”, никаких ритуалов с бубном (по крайней мере, на начальном уровне).

JUnit Jupiter — это современная версия JUnit (то, что обычно называют JUnit 6). В Boot-мире она уже давно default, и именно на ней мы строим тестовый каркас проекта.

Минимальный тест доменной сущности catalog-service может выглядеть так. Предположим, что у вас есть enum CourseLevel в пакете catalog.domain.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CourseLevelTest {

    @Test
    void basicLevelNameIsStable() {
        // expected: строковое имя enum фиксировано и не должно "случайно" поменяться
        // actual: то, что реально возвращает name()
        assertEquals("BASIC", CourseLevel.BASIC.name());
    }
}

Здесь сразу несколько важных вещей, которые хорошо “прочувствовать” на таком простом примере.

Во-первых, метод с @Test — это не “просто метод”, а контракт: JUnit найдёт его, выполнит и решит, прошёл тест или нет. Во-вторых, имя метода может быть почти любым (лишь бы это был валидный Java-идентификатор). Обычно используют стиль, который читается как предложение: basicLevelNameIsStable, parsesEnumValue, throwsOnInvalidInput.

В-третьих, тестовый класс не обязан быть public. Это даже удобно: меньше соблазна «случайно начать использовать тесты как библиотеку». Тесты — не API, тесты — страховка.

И ещё один момент, который удивляет новичков: тест не должен ничего печатать. Тест должен либо молча пройти, либо честно упасть. Если вы видите в тестах много System.out.println, это обычно знак, что тест пока выполняет роль отладчика, а не проверки. Отладчик тоже полезен, но он не заменяет assertions.

4. Assertions в JUnit

Самый частый “обман” на старте тестирования выглядит так: человек пишет тест, запускает его, он зелёный… и это вообще ничего не означает. Почему? Потому что тест был “пустой”: он ничего не проверял. Assertions (проверки) — это как вопрос преподавателя на экзамене: неважно, насколько уверенно вы пришли в аудиторию, важно, что вы сможете ответить на конкретный вопрос.

JUnit даёт набор базовых assertions, которых на старте более чем достаточно. Главное — понять их смысл и научиться читать сигнатуры.

Небольшая таблица «что чем проверять»:

Assertion Что проверяет Пример ситуации
assertEquals(expected, actual) Значения равны Вы ожидаете конкретное имя enum или число
assertNotNull(value) Значение не null Вы получили объект и хотите убедиться, что он реально создан
assertTrue(condition) Условие истинно Вы проверяете “флаг включён”, “список не пуст”
assertFalse(condition) Условие ложно Вы проверяете “флаг выключен”
assertThrows(type, executable) При выполнении кода выбрасывается исключение Вы проверяете fail-fast поведение при неправильном входе

assertEquals: «ожидаемое» и «фактическое»

Самая классическая проверка — сравнение двух значений. Важно не перепутать порядок аргументов: сначала expected, потом actual. Иначе при падении вы получите сообщение, которое сложно читать.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CourseTrackTest {

    @Test
    void valueOfParsesEnumName() {
        // Подготовка: получаем enum из строки (как будто пришло из конфигурации)
        CourseTrack track = CourseTrack.valueOf("SPRING");

        // Проверка: ожидаем конкретный enum-элемент
        assertEquals(CourseTrack.SPRING, track);
    }
}

Здесь мы не тестируем Spring, не тестируем Boot, не тестируем “контейнер”. Мы тестируем чистую Java-часть: поведение enum и предсказуемость строковых значений, которые потом могут прийти из конфигурации.

assertNotNull: «объект реально существует»

Эта проверка банальна, но полезна, когда вы только начинаете. Она помогает ловить ситуации, где что-то “не создалось” или “не вернулось”, и вы потом не проваливаетесь в NullPointerException где-нибудь через три строки.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

class MoneyTest {

    @Test
    void moneyHasCurrency() {
        // Arrange: создаём value-object
        Money price = new Money(19900, "GBP");

        // Assert: объект создан и содержит ожидаемую валюту
        assertNotNull(price);
        assertEquals("GBP", price.currency());
    }
}

Да, это выглядит очевидно. Но тесты часто и должны быть очевидными: их работа — не удивлять, а защищать от глупых случайностей.

assertTrue / assertFalse: «проверяем условие, а не конкретное значение»

Иногда вы не хотите сравнивать два конкретных числа, а хотите проверить свойство/условие. Например: “строка не пустая”, “сумма не отрицательная”, “флаг выключен”.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class CourseCardTest {

    @Test
    void courseSlugLooksNonEmpty() {
        // Данные для теста (в учебном примере — максимально короткие)
        CourseCard card = new CourseCard(
                "spring-boot",
                "spring-boot",
                "Spring Boot"
        );

        // Проверяем сразу два свойства slug:
        // 1) выглядит "похоже на наш формат"
        assertTrue(card.slug().startsWith("spring"));
        // 2) точно не пустой/не из пробелов
        assertFalse(card.slug().isBlank());
    }
}

Здесь я намеренно показываю короткую версию CourseCard (с тремя полями), потому что в учебном коде иногда полезно иметь упрощённый конструктор/record для демонстраций. Если в вашем проекте CourseCard содержит больше полей, сама идея не меняется: вы проверяете конкретное свойство, а не “всё сразу”.

assertThrows: «ожидаем падение и считаем это успехом»

assertThrows — это способ превратить “красный тест” в осмысленное намерение. Вместо “тест случайно упал, потому что всё сломалось” вы говорите: “вот здесь должно быть исключение, и это нормально”.

Самый простой пример, привязанный к нашему домену (и очень похожий на реальные боли конфигурации): неправильная строка не должна «тихо превращаться во что-то», она должна быть заметной ошибкой.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertThrows;

class CourseTrackParsingTest {

    @Test
    void valueOfThrowsOnWrongCase() {
        // Важно: мы передаём в assertThrows "исполняемый код", а не вызываем его заранее.
        // Здесь ожидаем IllegalArgumentException, потому что enum valueOf чувствителен к регистру.
        assertThrows(IllegalArgumentException.class, () ->
                CourseTrack.valueOf("spring"));
    }
}

Обратите внимание на лямбду () -> .... Мы не вызываем метод сразу, мы передаём JUnit “кусочек кода”, который нужно выполнить и проверить, что он падает ожидаемым исключением.

5. JUnit-тесты и @SpringBootTest

До этого места мы собирали именно обычный JUnit-каркас. И это уже полезно: enum, value-object, маленькая функция, parsing, fail-fast на чистой Java — всё это проверяется быстро и без Spring. Для таких вопросов @Test и assertions хватает с головой.

Но у Boot-проекта есть отдельный класс поломок: приложение может не собрать контекст вообще. Там уже мало вызвать один метод. Нужно проверить, что сервис как система поднимается, что wiring не развалился, а конфигурация не поссорилась с кодом. Для этого и существуют контекстные тесты со Spring.

Полезная граница выбора простая: если вопрос звучит как «эта Java-логика работает?», достаточно plain JUnit. Если вопрос звучит как «приложение вообще собирается и стартует?», начинается уровень Boot-контекста.

6. Типичные ошибки при написании тестов

Первые тесты обычно пишутся в режиме “я ещё не уверен, что делаю”, и это нормально. Ошибки здесь чаще всего не про «сложные концепции», а про мелкие привычки, которые либо ломают смысл теста, либо делают его слишком дорогим. Хорошая новость: почти все эти ошибки лечатся парой простых принципов и внимательностью.

Ошибка №1: тест есть, но assertions нет.
Новичок пишет метод с @Test, запускает, видит зелёный результат и радуется. А потом внезапно понимает, что тест ничего не проверял: он просто выполнился. Такие тесты создают ложное ощущение безопасности. Если вы хотите “проверить факт”, этот факт должен быть выражен assertEquals, assertTrue или хотя бы assertNotNull.

Ошибка №2: путаница с src/main/java и src/test/java.
Иногда тестовые классы по ошибке оказываются в src/main/java, потому что IDE “автоматически создала класс не там”. Это приводит к неприятным последствиям: тестовый код может уехать в прод-артефакт, а тестовые зависимости начнут восприниматься как обязательные для runtime. Правильная привычка простая: любой класс, который существует только ради проверки, должен жить в src/test/java.

Ошибка №3: @SpringBootTest используется везде подряд.
Это типичная “Boot-ошибка”: раз Spring умеет поднимать контекст, значит будем делать так всегда. В результате тесты становятся медленными, и вы начинаете реже их запускать. А реже запускать тесты — это очень надёжный путь к тому, что они превращаются в декоративный элемент. Контекст нужен тогда, когда вы проверяете сборку приложения, wiring, конфигурацию. Для чистой Java-логики достаточно обычного JUnit.

Ошибка №4: тестовые зависимости подключаются как implementation.
Если по невнимательности подключить spring-boot-starter-test не как testImplementation, а как implementation, вы тащите тестовый стек в боевой classpath. Это не конец света, но это плохая гигиена проекта и «размывание границы» между тем, что нужно приложению, и тем, что нужно проверкам. У Boot-проекта и так хватает зависимостей; давайте не будем добавлять ещё и тестовые в прод.

Ошибка №5: assertEquals написан в неправильном порядке.
Когда в assertEquals местами меняют expected и actual, при падении теста вы получаете сообщение, которое тяжело читать: кажется, будто ожидали одно, а получили другое — но вы сами это перепутали. Это мелочь, но мелочи в тестах — как песок в клавиатуре: сначала “ну ерунда”, а потом клавиши начинают скрипеть.

1
Задача
Spring Boot, 26 уровень, 1 лекция
Недоступна
Первый JUnit-тест без Spring-контекста
Первый JUnit-тест без Spring-контекста
1
Задача
Spring Boot, 26 уровень, 1 лекция
Недоступна
Базовый Spring test skeleton с ApplicationContext
Базовый Spring test skeleton с ApplicationContext
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ