1. Ручной контекст — тупик
Если честно, руками поднимать AnnotationConfigApplicationContext в тесте — это как вручную заводить машину толканием. Один раз — можно, чтобы почувствовать механику. Но каждый день так ездить… ну, вы быстро станете человеком с очень сильными ногами и очень грустным лицом.
Руками мы уже один раз поднимали AnnotationConfigApplicationContext, и для первого знакомства это было правильно. Так лучше видно, что тест действительно работает с реальным контейнером, а не с «как будто Spring» сборкой на new.
Проблема не в том, что “ручной контекст” не работает. Он работает. Проблема в том, что как только контекстных тестов становится больше одного-двух, вы сталкиваетесь с повторяющимся кодом, отсутствием нормальной интеграции с жизненным циклом JUnit и неудобным доступом к bean-ам прямо из тестового класса. И вот вы уже пишете “тестовый bootstrap”, который подозрительно напоминает мини-Spring. Отличный план: написать Spring, чтобы тестировать Spring.
Нам нужен механизм, который:
- поднимает контекст автоматически для тестового класса,
- позволяет удобно доставать бины через внедрение,
- делает конфигурацию теста частью контракта (читаемой прямо по аннотациям),
- не заставляет вас закрывать контекст вручную и думать о жизненном цикле.
Этим механизмом и является связка JUnit Jupiter + Spring Test + @SpringJUnitConfig.
2. SpringExtension и JUnit Jupiter
В этом разделе мы чуть-чуть заглянем под капот, но аккуратно: настолько, чтобы у вас появилась ментальная модель, а не “я просто поставил аннотацию и оно как-то работает”. Это важно, потому что тесты — штука, которая ломается всегда “в самый неподходящий момент”, а понимать механизм полезнее, чем шаманить.
JUnit Jupiter умеет подключать расширения через @ExtendWith(...). Расширение — это кусок кода, который JUnit вызывает в нужные моменты жизненного цикла теста: до создания тестового класса, перед тестом, после теста и так далее. Spring даёт расширение SpringExtension, которое и делает магию “подними контекст, внедри бины, дай мне ApplicationContext и пошли тестироваться”.
То есть вместо того, чтобы в каждом тесте делать new AnnotationConfigApplicationContext(...), мы говорим JUnit Jupiter: “пожалуйста, запускай тест вот с этим расширением”. А расширение Spring уже само:
- создаст ApplicationContext по указанной конфигурации,
- внедрит зависимости в тестовый класс,
- отдаст вам бины по @Autowired,
- и даже постарается кешировать контекст между тестами (об этом позже).
Схематично процесс выглядит так:
flowchart TD
A["JUnit Jupiter запускает тестовый класс"] --> B["@ExtendWith(SpringExtension)"]
B --> C["Spring TestContext Framework"]
C --> D["@ContextConfiguration / @SpringJUnitConfig"]
D --> E["Создание ApplicationContext"]
E --> F["Внедрение beans в test instance"]
F --> G["Запуск методов @Test"]
И вот тут появляется приятная штука: нам не нужно руками писать @ExtendWith(SpringExtension.class), если мы используем @SpringJUnitConfig, потому что @SpringJUnitConfig — это “удобная сборка” для тестов. Переходим к ней.
3. Аннотация @SpringJUnitConfig
Сейчас будет момент, когда мозг хочет сказать: “Ой, ещё одна аннотация, давайте просто запомним”. Но мы сделаем лучше: поймём, что именно она делает. Тогда вы не будете бояться ни ошибок, ни чужих тестов в старом проекте.
@SpringJUnitConfig — это аннотация из Spring Test (spring-test), которая по сути объединяет две вещи:
- подключает JUnit Jupiter extension Spring (SpringExtension),
- задаёт конфигурацию тестового контекста (@ContextConfiguration) — то есть отвечает на вопрос “какой именно контекст поднимать?”.
Можно думать о ней как о фразе:
“Этот тест — контекстный. Подними Spring-контекст вот по этой конфигурации.”
Выглядит она обычно так:
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(TestConfig.class) // Указываем, какой конфиг использовать для тестового контекста
class SomeSpringTest {
}
Если вы начнёте гуглить (позже, когда вырастете, а пока не надо), вы увидите, что @SpringJUnitConfig — составная (meta-)аннотация. Примерно так, концептуально:
// псевдокод, просто чтобы понять идею
@ExtendWith(SpringExtension.class)
@ContextConfiguration
public @interface SpringJUnitConfig {
}
И отсюда следует главный вывод: @SpringJUnitConfig не “магия”, а компактный, читаемый способ подключить Spring Test к JUnit Jupiter и указать, какой контекст грузим.
Мини-нюанс про зависимости
Поскольку мы в курсе работаем без Spring Boot runtime, никто за нас spring-test не подтянет “по умолчанию”. Поэтому в Gradle у вас должны быть тестовые зависимости на JUnit Jupiter и Spring Test. Обычно это выглядит примерно так:
dependencies {
// зависимости для тестов
testImplementation("org.junit.jupiter:junit-jupiter")
// Spring Test: SpringExtension, TestContext Framework и @SpringJUnitConfig
testImplementation("org.springframework:spring-test")
}
tasks.test {
// Важно: включаем запуск через JUnit Platform
useJUnitPlatform()
}
Если этого нет, то @SpringJUnitConfig просто не найдётся, и IDE честно скажет: “такой аннотации не существует”. Она не вредничает — она просто не телепат.
4. Минимальный пример с @Autowired
Сейчас соберём “костяк” контекстного теста, который будет выглядеть почти как настоящие тесты в проекте ContextFlow, но при этом останется маленьким и понятным. Наша цель здесь — увидеть полный цикл: конфигурация → поднятие контекста → внедрение → проверка.
Начнём с крошечной конфигурации. Пусть она отдаёт один bean — имя приложения (просто как пример).
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Говорим Spring, что это класс конфигурации
class TestConfig {
@Bean // Регистрируем String как бин в контексте
String appName() {
return "contextflow-test";
}
}
Теперь тест. Самое простое: внедрим String в тестовый класс и проверим значение.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // Поднимаем контекст по TestConfig
class SpringContextTest {
@Autowired
String appName; // Это поле заполнит Spring (через SpringExtension)
@Test
void injectsBeanFromContext() {
// Проверяем, что внедрение реально сработало и бин тот самый
assertEquals("contextflow-test", appName);
}
}
Важно понять, что здесь происходит. Поле appName — это не “магическое поле теста”. Это обычное поле, которое Spring заполняет так же, как заполняет зависимости в любом другом bean. Просто тестовый класс тоже получает “инъекцию”, потому что его создаёт JUnit, но SpringExtension встраивается в процесс и делает dependency injection.
Да, это field injection. Да, в production-коде мы его не любим. Но в тестовом классе это допустимо как технический доступ к бинам. Мы сейчас тестируем контейнерную сборку и нам важно быстро и ясно получить нужные объекты.
5. Конфигурация: Java и XML
На практике вы будете тестировать две похожие, но разные вещи. Иногда вы хотите поднять контекст по Java-конфигурации (что типично для нашего проекта), а иногда — специально поднять небольшой XML-фрагмент (потому что у нас есть legacy/*.xml, и мы обещали себе не паниковать при виде угловых скобок).
@SpringJUnitConfig поддерживает оба сценария. У неё есть два “главных пути”:
- указать конфигурационные классы (Java config),
- указать locations (XML или другие ресурсы конфигурации).
Сведём это в небольшую таблицу — она полезна именно как “карта выбора”:
| Что тестируем | Как чаще всего указывать контекст | Пример |
|---|---|---|
| Java config / modular config | @SpringJUnitConfig(SomeConfig.class) | @SpringJUnitConfig(ContextFlowAppConfig.class) |
| Legacy XML фрагмент | @SpringJUnitConfig(locations = "classpath:...") | @SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml") |
Вариант A: Java-конфигурация
Представим, что у нас есть корневой конфиг, который собирает ContextFlow (в реальном проекте он может называться по-разному, главное — идея).
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration // Корневой конфиг, который "склеивает" модули приложения
@Import({CoreConfig.class, ReportingConfig.class}) // Подключаем подконфиги
class ContextFlowAppConfig {
}
Тогда тестовый класс выглядит так:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(ContextFlowAppConfig.class) // Поднимаем "сборный" контекст приложения
class ContextFlowWiringTest {
@Autowired
Object someBean; // Тут в реальном тесте будет конкретный бин/сервис
@Test
void contextStarts() {
// Smoke-проверка: контекст поднялся и зависимости резолвятся
assertNotNull(someBean);
}
}
Сейчас это выглядит глупо (проверять Object), но мы специально держим пример маленьким. В следующей лекции вы превратите это в настоящий smoke test: будете проверять, что ключевые сервисы реально резолвятся. Но инструмент подъёма контекста — уже тот самый.
Вариант B: XML-контекст
Теперь XML. Пусть мы хотим проверить, что legacy notification module ещё жив и не умер от старости.
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml") // Грузим XML из classpath
class LegacyXmlContextTest {
@Autowired
ApplicationContext context; // Удобный "термометр": если внедрилось — контекст точно поднялся
@Test
void loadsXmlFragment() {
// Минимальная проверка: контекст есть и доступен в тесте
assertNotNull(context);
}
}
Здесь мы внедрили ApplicationContext просто как “термометр”: он показывает, что контекст поднялся. Позже мы будем внедрять конкретный NotificationSender или AuditWriter, чтобы проверять, что нужные bean-ы реально создаются.
6. Способы внедрения бинов в тесте
На этом этапе часто возникает вопрос: “Если в production-коде мы учили constructor injection, почему в тесте мы вдруг пишем @Autowired на поле?” Хороший вопрос. И тут важно не скатиться в догму “так можно/нельзя”, а понять мотивацию.
Тестовый класс — это не business-класс, его не читают как архитектурную единицу. Он — инструмент. В тесте важны две вещи: ясность и скорость понимания. Поэтому самый частый вариант — действительно @Autowired поле. Оно делает зависимость видимой и не требует лишнего шаблонного кода.
Но если вы хотите сохранить привычку к constructor injection и вам так читабельнее — Spring позволяет внедрять зависимости и в конструктор тестового класса. Чтобы в примере было меньше магии, явно пометим этот конструктор @Autowired.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // Контекст поднимется для этого теста; при совпадении конфигурации Spring Test обычно его кеширует
class ConstructorInjectedTest {
private final String appName;
@Autowired
ConstructorInjectedTest(String appName) {
this.appName = appName;
}
@Test
void constructorInjectionWorks() {
// Проверяем, что значение дошло до конструктора
assertEquals("contextflow-test", appName);
}
}
Есть и третий стиль, который иногда удобен именно для маленьких проверок: внедрение прямо в параметры тестового метода. Это полезно, когда вы хотите “не хранить состояние” в тестовом классе и сделать зависимость максимально локальной.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // Всё ещё тот же механизм: SpringExtension + тестовый контекст
class ParameterInjectedTest {
@Test
void methodParameterInjectionWorks(@Autowired String appName) {
// Зависимость живёт прямо в сигнатуре теста — удобно для маленьких проверок
assertEquals("contextflow-test", appName);
}
}
Если вы новичок, не пытайтесь сразу выбрать “единственно правильный” стиль. В рамках этого курса нормальная стратегия такая: в тестах используйте @Autowired на поле как самый понятный путь, а конструктор/параметры — когда они реально улучшают читабельность.
Отдельно подчеркну важную мысль из методики курса: даже если вы используете field injection в тестах, не переносите эту привычку в production-код сервисов. Тест — это лаборатория, а не боевой самолёт. В лаборатории можно стоять в шлёпках, но в кабину пилота в шлёпках лучше не лезть.
7. Контракт теста и ширина контекста
Сейчас очень легко совершить типичную ошибку: вы узнали, что @SpringJUnitConfig может поднять контекст, и захотели использовать его везде. Это нормальная эмоция. Spring-контекст в тесте ощущается как “пульт управления всей системой”: нажал кнопку — и всё работает.
Но контекстный тест всегда отвечает на вопрос: какой контекст мы подняли и зачем? Если тест поднимает “весь ContextFlow” ради проверки одной маленькой настройки — это тест не сильный, а ленивый. Он, во‑первых, медленнее, во‑вторых, сложнее отлаживается, а в‑третьих, при падении даёт больше шума.
Хорошая дисциплина выглядит так: вы выбираете конфигурацию настолько узко, насколько позволяет смысл теста. Если вы тестируете legacy XML-фрагмент — поднимайте только его. Если тестируете один модуль конфигурации — поднимайте только этот модуль. Если тестируете сборку всего приложения — да, поднимайте root config, но делайте это осознанно.
Вот маленький пример “узкого” теста: поднимаем только один конфиг, который объявляет MessageSource (или любую инфраструктуру). Нам не нужны сервисы, не нужны сценарии, не нужен аспект. Нам нужен один bean и его свойства.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(MessagesConfig.class) // Поднимаем только узкий конфиг, который нужен тесту
class MessageSourceWiringTest {
@Autowired
MessageSource messageSource; // Проверяем наличие инфраструктурного бина
@Test
void messageSourceIsPresent() {
// Быстрый сигнал: бин есть, контекст собрался как ожидалось
assertNotNull(messageSource);
}
}
Вы заметите, что тест становится “узким” не ради философии, а ради практики: если он падает, вы сразу знаете, где искать проблему.
8. Кеширование тестового контекста
Есть одна штука, которая сначала кажется магией, а потом становится источником странных багов. Поэтому лучше предупредить заранее, пока вы ещё не успели наступить на грабли всем лицом.
Spring TestContext Framework обычно кеширует ApplicationContext между тестами, если конфигурация совпадает. Это сделано для скорости: поднимать контекст каждый раз — довольно дорогая операция. Поэтому часто вы увидите поведение: первый тест с контекстом запускается заметно дольше, а остальные — быстрее.
Почему это важно? Потому что если вы где-то мутируете состояние singleton-bean’а в одном тесте, то другой тест, который использует тот же контекст, может получить “грязное” состояние. И вы получите тесты, которые “падают только если запускать все вместе” — классика жанра, которую любят все разработчики мира (нет).
В рамках этого курса самый здравый подход прост: делайте ваши сервисы и инфраструктуру максимально stateless, а тесты — не мутирующими глобальные singleton-ы. Если вам нужно “собирать данные о событии” или “посчитать вызовы”, делайте это через отдельные тестовые бины (коллекторы/стабы), но так, чтобы их состояние не влияло на другие тесты.
Мы не будем сейчас уходить в отдельный разговор о том, как сбрасывать кеш или принудительно пересоздавать контекст — это уже слишком далеко от главной цели лекции. Наша цель — чтобы вы понимали, почему иногда тесты “влияют друг на друга”, и не создавали такие ситуации в учебном проекте.
9. Типичные ошибки при использовании @SpringJUnitConfig
Ошибка №1: “Поставил @SpringJUnitConfig, а тест всё равно NullPointerException”.
Чаще всего это означает, что вы ожидаете внедрение в объект, который не управляется Spring Test. Например, вы создали объект через new внутри теста и думаете, что @Autowired в нём сработает. Spring внедряет зависимости только в тестовый класс (и в бины контекста), а не во всё подряд, что вы создаёте руками. Если объект нужен с DI — сделайте его bean-ом или собирайте вручную, как unit test.
Ошибка №2: “Аннотация не находится / нет @SpringJUnitConfig в импортах”.
Почти всегда проблема в зависимостях: не подключён spring-test или не подключён JUnit Jupiter. В чистом Spring-проекте без Spring Boot никто за вас это не добавит. Проверьте testImplementation("org.springframework:spring-test") и testImplementation("org.junit.jupiter:junit-jupiter").
Ошибка №3: “Я указал XML, но Spring говорит, что ресурс не найден”.
Тут обычно виноват путь. Для locations используйте classpath: (например, classpath:legacy/legacy-notification-context.xml) и убедитесь, что файл реально лежит в src/test/resources или src/main/resources. Путь должен быть именно “путь внутри classpath”, а не путь на диске. Абсолютные пути вида C:\projects\... в тестах — это почти гарантированная поломка воспроизводимости.
Ошибка №4: “Тест поднимает весь контекст и падает по какой-то далёкой причине, я вообще не понимаю, при чём тут мой тест”.
Это симптом слишком широкого контекста. Если тест проверяет одну маленькую вещь, а поднимает весь ContextFlow, то при падении вы увидите ошибки про профили, ресурсы, conversion, legacy XML и всё остальное — даже если вы хотели проверить одну строку в Environment. Лечится просто: сузьте конфигурацию теста до нужного модуля.
Ошибка №5: “Я использую @Autowired в тестах и теперь хочу так же делать в production-коде, потому что короче”.
Понимаю. Мы все любим, когда кода меньше. Но field injection в бизнес-сервисах возвращает нас к скрытым зависимостям и ухудшает тестируемость. В тестовом классе @Autowired — это удобная “ручка управления” для диагностики контейнера. В production-коде — это привычка, которая потом бьёт по рукам (особенно в большом проекте). Держите это различие в голове, и вы сэкономите себе много времени в будущем.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ