1. Когда нужен @SpringBootTest
Пустой smoke-test уже закрыл один важный вопрос: Boot-контекст вообще поднимается или нет. Но довольно быстро этого мало. Хочется проверить, что из контекста реально достаётся нужный сервис, что CatalogProperties забиндились, что override-свойство дошло до приложения.
Вот здесь @SpringBootTest перестаёт быть просто фоном для contextLoads() и становится рабочим инструментом full-context проверки. А когда нужен полный контроль над самим моментом старта, профилем или ожидаемым падением, удобнее запускать SpringApplication вручную.
Если вопрос звучит как «правильно ли работает enum, value-object или маленький кусок Java-логики», Spring здесь вообще не нужен. Но если вопрос звучит как «собирается ли catalog-service как приложение, доступны ли его бины, биндится ли конфигурация», обычного JUnit уже мало. @SpringBootTest нужен именно для этой второй группы вопросов.
То есть мы проверяем не один метод, а приложение как систему: wiring, @ConfigurationProperties, validation, auto-configuration и стартовую механику. Поэтому такой тест тяжелее обычного JUnit, но это честная цена за проверку Boot-уровня.
Что делает @SpringBootTest
@SpringBootTest просит Boot поднять ApplicationContext почти так же, как при обычном SpringApplication.run(...). Поэтому такие тесты часто падают ещё до первого assert: wiring, binding, validation и startup hooks срабатывают раньше тела метода.
На нашем уровне этого уже достаточно, чтобы отделить его от пустого smoke-test. contextLoads() задаёт один общий вопрос — «приложение вообще живо?». Здесь мы используем тот же full context уже для более адресных проверок.
2. Как выбирается приложение для запуска
Когда вы ставите @SpringBootTest, Boot должен понять, какое именно Spring Boot-приложение нужно поднять. Обычно он находит главный класс с @SpringBootApplication по структуре пакетов. Поэтому тесты проще держать внутри базового package com.example.catalogservice или его подпакетов: тогда Boot поднимает именно то приложение, которое вы запускаете в реальности.
Если хотите сделать выбор приложения явным, можно указать класс прямо в аннотации:
import com.example.catalogservice.CatalogServiceApplication;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(classes = CatalogServiceApplication.class)
class CatalogBootContextTest {
@Test
void bootContextStarts() {
// Пустой на вид метод здесь нормален:
// основная работа всё равно происходит на этапе старта контекста.
}
}
Такой вариант особенно полезен, когда вы хотите убрать лишние догадки из teaching-примера: сразу видно, какое приложение пытается поднять тест.
4. Что поднимается в full context
Важно понимать, что full context тест — это не «проверка одного бина». Это проверка того, что вся машина собирается: ваши @Configuration, @ConfigurationProperties, auto-configuration от Boot, wiring через constructor injection, плюс инфраструктурные компоненты из starter’ов. Именно поэтому такой тест умеет ловить очень полезные вещи: например, что CatalogProperties больше не биндится, потому что вы переименовали поле; или что validation завалило старт, потому что теперь maxFeaturedCount не может быть нулём.
Отдельная тонкость — web-часть. В catalog-service подключен spring-boot-starter-webmvc, значит приложение в принципе web-ориентированное. Но @SpringBootTest по умолчанию не обязан поднимать реальный embedded-сервер на порту, иначе у вас тесты очень быстро начнут конфликтовать по портам и вообще станут нервными. Обычно для базовых контекст-проверок это прекрасно: нам важно собрать контекст и увидеть, что все бины создаются, а не открыть порт 8080.
У @SpringBootTest есть настройка webEnvironment, и полезно хотя бы на уровне карты местности знать, что там бывает:
| webEnvironment | Что происходит | Когда это уместно |
|---|---|---|
| MOCK | Поднимается web-контекст без реального сервера | Когда вы хотите проверить wiring web-слоя без запуска порта |
| NONE | Поднимается не web контекст | Когда тесту вообще не нужен web-слой |
| RANDOM_PORT | Стартует сервер на случайном порту | Когда нужен реальный HTTP-старт (для нашего сегодняшнего минимума это обычно избыточно) |
Мы сегодня остаёмся в парадигме «контекст собрался — отлично». Если вам вдруг хочется в одном тесте проверить и то, что сервис стартует, и то, что он отвечает на HTTP-запросы, и то, что JSON красивый, и то, что фильтрация работает — остановитесь и глубоко вдохните. Это уже не минимальный baseline, это уже попытка за один тест «застраховать весь мир». Так тоже можно, но это уже другой уровень и другой курс, и другой уровень терпения вашего ноутбука.
5. Минимальные проверки в @SpringBootTest
На этом этапе у нас цель максимально прагматичная: если catalog-service перестал собираться как приложение (не хватает бина, конфиг не биндится, validation падает), тест должен стать красным. И не через сложные сценарии, а максимально прямолинейно.
Начнём с самого простого и полезного: проверим, что CourseCatalogService реально доступен из контекста. Это не «проверка бизнес-логики», это проверка wiring-уровня. То есть мы подтверждаем, что Boot смог создать сервис и подставить ему зависимости.
import com.example.catalogservice.catalog.service.CourseCatalogService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
class CourseCatalogServiceBootTest {
// Достаём бин прямо из контекста: это проверка wiring, а не бизнес-логики.
private final CourseCatalogService service;
// Constructor injection в тесте делает зависимости явными и понятными.
@Autowired
CourseCatalogServiceBootTest(CourseCatalogService service) {
this.service = service;
}
@Test
void serviceIsAvailableFromContext() {
// Минимальный smoke-assert: сервис создан и доступен из ApplicationContext.
assertNotNull(service);
}
}
Здесь есть маленький приятный момент: мы используем constructor injection даже в тесте. Тестовый класс не является Spring-бином в обычном смысле, но Spring Test умеет подставлять зависимости, и такой стиль заставляет вас держать зависимости явными (а не «где-то там инъекцией в поле прилетело»).
Теперь проверим второй важный слой нашего проекта: type-safe конфигурацию. CatalogProperties — это центр управления app.catalog.*. Если он перестал биндиться, то приложение может либо упасть при старте, либо начать вести себя странно. И в обоих случаях лучше узнать об этом в тестах.
import com.example.catalogservice.config.CatalogProperties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
class CatalogPropertiesBootTest {
// Проверяем, что typed-конфигурация вообще биндится и попадает в контекст.
private final CatalogProperties properties;
@Autowired
CatalogPropertiesBootTest(CatalogProperties properties) {
this.properties = properties;
}
@Test
void titleIsNotBlank() {
// Базовый инвариант: заголовок не должен быть пустым после биндинга конфигурации.
assertTrue(!properties.title().isBlank());
}
}
Обратите внимание на характер assertions: они короткие и не пытаются доказать «всё на свете». Мы не тестируем, что «фильтрация курсов идеальна». Мы тестируем, что контекст поднялся и базовые инварианты конфигурации выглядят разумно.
6. Inline test properties
Один из самых полезных трюков @SpringBootTest на уровне минимального baseline — возможность подложить свойства прямо в аннотацию. Это особенно приятно в конфигурационно-ориентированном проекте вроде catalog-service: мы можем проверить, что свойство реально влияет на то, что биндится в CatalogProperties, не трогая application.yaml и не устраивая «а давай ради одного теста поменяем конфиг проекта».
Синтаксис выглядит так: @SpringBootTest(properties = "..."). Обычно свойства задают в формате key=value. На практике вы можете передать одно свойство строкой или несколько — массивом строк.
Проверим, что inline-свойство действительно перезаписывает значение из YAML и доходит до typed config:
import com.example.catalogservice.config.CatalogProperties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertFalse;
@SpringBootTest(properties = "app.catalog.startup-report-enabled=false")
class CatalogPropertiesOverrideBootTest {
// В этот тестовый контекст мы специально подкладываем override-свойство.
private final CatalogProperties properties;
@Autowired
CatalogPropertiesOverrideBootTest(CatalogProperties properties) {
this.properties = properties;
}
@Test
void inlinePropertyOverridesYaml() {
// Проверяем именно механизм приоритетов: значение из теста должно победить YAML.
assertFalse(properties.startupReportEnabled());
}
}
Почему это важно? Потому что это моделирует реальную жизнь Boot-приложения: в runtime свойства могут приходить не только из application.yaml, но и через environment variables, system properties, CLI args и т.д. Inline test properties — это «учебная песочница» для этой идеи: не меняя проектные файлы, вы создаёте контролируемое окружение для теста.
Ещё один маленький нюанс, который приятно знать: такие свойства имеют высокий приоритет. Если у вас в application.yaml написано одно, а в тесте вы подложили другое, то для этого тестового контекста победит значение из теста. Это ровно то, что нам нужно: тест должен быть самодостаточным и управлять своим сценарием.
Если нужно несколько свойств сразу, обычно делают так:
@SpringBootTest(properties = {
// Несколько override в одном тесте: удобно для маленьких «конфигурационных» сценариев.
"app.catalog.title=Test Catalog",
"app.catalog.max-featured-count=2"
})
class CatalogMultipleOverridesBootTest {
}
И здесь появляется важная эксплуатационная мысль: каждая уникальная комбинация контекста и свойств может создавать отдельный контекст. Spring старается кэшировать контексты между тестами, но если у вас десять тестовых классов и каждый задаёт разные inline-properties, вы фактически десять раз поднимете приложение. Поэтому inline-properties — штука мощная, но её стоит использовать точечно и осознанно, а не «в каждом тесте по приколу».
7. Как использовать @SpringBootTest аккуратно
Полный контекст — это как заказать доставку всего меню ресторана, чтобы проверить, что суп солёный. Вроде бы работает, но становится дорого. Поэтому на уровне минимального baseline важно держать две идеи одновременно: @SpringBootTest даёт нам очень ценную проверку «приложение собирается», но если мы начнём решать им все задачи подряд, тесты станут медленными, шумными и начнут раздражать (а раздражающие тесты имеют неприятное свойство — их отключают).
Прагматичная стратегия для нашего курса выглядит так: full context тесты пишем там, где цель действительно про сборку приложения. Это проверки уровня «бин создаётся», «конфигурация биндится», «контекст стартует». Для всего остального (например, для проверки чистой Java-логики enum’ов, утилит или каких-то маленьких функций) используем обычные JUnit 6-тесты без Spring.
Ещё одна важная привычка — не превращать @SpringBootTest в «тест на всё сразу». Если вы заинжектили в тестовый класс десять бинов и сделали двадцать assertions, то при падении у вас будет два варианта: либо долго разбираться, что именно сломалось, либо грустно смотреть на красный тест и думать «ну, что-то там не работает». Гораздо лучше, когда тест отвечает на один человеческий вопрос. Например, «Boot может создать CourseCatalogService?» или «CatalogProperties биндится и принимает override?».
И последнее, что стоит проговорить: да, @SpringBootTest часто будет печатать логи. Это нормально, потому что приложение реально стартует внутри теста. Если логи начинают мешать, это обычно лечится настройкой уровней логирования (но мы не будем превращать сегодняшнюю лекцию в “Logback-battle”, у нас другая цель).
8. Типичные ошибки при работе с @SpringBootTest
Ошибка №1: использовать @SpringBootTest для тестирования простой Java-логики.
Это самая частая «ошибка доброты»: студент хочет быть молодцом и решает, что раз мы учим Spring Boot, то и тесты должны быть «все со Spring». В результате тесты становятся медленными, а вы проверяете то, что и так проверяется обычным JUnit 6 за миллисекунды. Если вы тестируете метод, который вообще не зависит от Spring-контекста, лучше не поднимать контекст — это экономит время и делает тест проще.
Ошибка №2: ожидать, что @SpringBootTest — это “почти как юнит-тест, только с аннотацией”.
На практике это интеграционный тест уровня контекста. Он поднимает много инфраструктуры, и его падение может происходить ещё до входа в @Test метод. Новичков это пугает: «я даже не дошёл до assertions». Но именно так и должно быть: если контекст не собрался, то дальше тестировать нечего — приложение не стартует.
Ошибка №3: положить тестовый класс вне базового пакета приложения и получить “не найден @SpringBootConfiguration”.
Это выглядит как «я же просто создал тест», а Boot отвечает «я не знаю, что запускать». Лечится либо правильным расположением тестов (внутри com.example.catalogservice), либо явным указанием classes = CatalogServiceApplication.class. В учебном проекте проще всего дисциплинированно держать тесты в том же package-дереве, что и приложение.
Ошибка №4: в одном @SpringBootTest проверять сразу всё приложение.
Соблазн понятный: раз мы подняли контекст, «давайте заодно» проверим и сервис, и репозиторий, и конфиг, и ещё пару фильтров. Но потом этот тест ломается, а вы не понимаете — что именно. Лучше иметь два-три коротких теста с одной целью, чем один большой тест-комбайн, который сообщает только “всё плохо”.
Ошибка №5: злоупотреблять inline properties так, что каждый тест поднимает новый контекст.
Inline test properties — отличный инструмент. Но если вы в каждом тестовом классе задаёте уникальный набор свойств, Spring не сможет эффективно переиспользовать кэш контекстов, и тесты начнут греться как тостер на максималках. На baseline-уровне обычно достаточно одного-двух сценариев с overrides, чтобы доказать, что механизм работает, и не превращать suite в марафон.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ