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, при падении теста вы получаете сообщение, которое тяжело читать: кажется, будто ожидали одно, а получили другое — но вы сами это перепутали. Это мелочь, но мелочи в тестах — как песок в клавиатуре: сначала “ну ерунда”, а потом клавиши начинают скрипеть.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ