1. Управляемая среда тестового контекста
Если смотреть на тесты как на живых существ (да, я тоже иногда разговариваю с тестами — это профессиональная деформация), то они обожают две вещи: предсказуемость и тишину. Предсказуемость — это когда ID не случайные, пути вывода не зависят от компьютера, а поведение “режима приложения” не меняется из-за того, что вы вчера запускали dev, а сегодня — demo. Тишина — это когда тест не пишет гигабайты в консоль и не создаёт файлы в корне проекта, оставляя после себя “археологические слои”.
Smoke test уже показал главное: контекст вообще поднимается, wiring не развален. Теперь возникает более приземлённый вопрос: как заставить этот контекст подниматься в одном и том же test-режиме, а не в случайной смеси профилей, старых свойств и шумных реализаций.
Наш ContextFlow — non-web приложение без БД, но оно всё равно живёт в окружении: профили (dev/demo/test), properties (contextflow.*), выбор реализаций (ConsoleAuditWriter vs FileAuditWriter, UuidOrderIdGenerator vs DeterministicOrderIdGenerator). В обычном запуске это круто: вы меняете окружение — меняется поведение. В тестах это может стать источником хаоса: вы ожидали, что тест создаст заказ с предсказуемым ID, а он вдруг сгенерировал UUID; вы ожидали, что отчёты пишутся в build/, а они внезапно оказались в каком-то “старом” пути из предыдущего запуска.
Поэтому сегодня мы учимся “прикручивать” окружение к тесту намертво, но аккуратно. Для этого у нас есть три основные ручки: @ActiveProfiles, @TestPropertySource и test-specific @Configuration (с stub beans). Важно понимать: это не “магия ради магии”, а способ сделать тестовые условия частью контракта теста. То есть чтобы любой человек (включая вас через две недели) открыл тест и сразу увидел: какой профиль активен, какие свойства переопределены и какие зависимости подменены.
2. @ActiveProfiles: профили для тестов
Профили в Spring мы уже обсуждали раньше: это способ собрать разные варианты одного и того же приложения без if-else внутри бизнес-кода. В тестах профили становятся ещё важнее, потому что тесты — это не “ещё один запуск приложения”, а отдельный режим жизни, где вы чаще всего хотите: детерминированность, минимум побочных эффектов и быстрые проверки.
Аннотация @ActiveProfiles("test") делает простую вещь: она говорит Spring Test, какие активные профили надо выставить в Environment при старте тестового контекста. И дальше контейнер собирается так, как будто приложение запустили в профиле test: подтягиваются @Profile("test") бины, исключаются @Profile("dev") и @Profile("demo"), и вообще вся ваша profile-aware конфигурация начинает работать на тест.
Чтобы это было не абстракцией, полезно иметь в голове “карту” наших профилей именно в ContextFlow. Это не список “всех возможных”, а ровно те точки, которые для учебного проекта обычно важны:
| Компонент / порт | dev (локально, просто) | demo (демонстрация) | test (детерминированно) |
|---|---|---|---|
| OrderIdGenerator | UUID (реалистично, но случайно) | может быть UUID или фиксированный | детерминированный генератор |
| NotificationSender | консольный | консольный/“показательный” | NoOp или stub |
| AuditWriter | консольный | файловый в build/ | в build/ или in-memory/no-op |
| пути вывода отчётов | build/… | build/… | build/test-… (чтобы не смешивать) |
Главный смысл: тесту часто нужен профиль test, даже если в “реальном запуске” вы чаще используете dev.
Минимальный тест, который проверяет, что профиль реально повлиял на wiring, может выглядеть так (обратите внимание: мы проверяем не “бизнес-результат”, а именно вариант сборки):
import com.example.contextflow.domain.ports.OrderIdGenerator;
import com.example.contextflow.infrastructure.id.DeterministicOrderIdGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertTrue;
// Поднимаем Spring-контекст только из указанной конфигурации (без запуска всего приложения)
@SpringJUnitConfig(classes = com.example.contextflow.config.core.ContextFlowAppConfig.class)
// Явно фиксируем профиль, чтобы не зависеть от IDE/параметров запуска
@ActiveProfiles("test")
class TestProfileWiringTest {
// Инжектим порт: нас интересует не конкретная реализация по имени, а то, что выбрал контейнер
@Autowired
OrderIdGenerator orderIdGenerator;
@Test
void usesDeterministicGeneratorInTestProfile() {
// Проверяем именно "вариант сборки": в test-профиле должен быть детерминированный генератор
assertTrue(orderIdGenerator instanceof DeterministicOrderIdGenerator);
}
}
Здесь есть один важный нюанс из “реальной жизни Spring”, который полезно держать в голове уже сейчас. Если на сервисный слой у вас включён AOP (а в нашем проекте он включён: ServiceTimingAspect), то некоторые бины могут оказаться проксированными, и проверка вида bean.getClass() == SomeClass.class может неожиданно сломаться. Поэтому для “кто это по сути” чаще подходит instanceof или проверка по интерфейсу, как выше.
И ещё один нюанс: @ActiveProfiles в тесте — это не “фича ради красоты”. Это способ защититься от ситуации, когда тест случайно запускается с профилем по умолчанию. В результате вместо NoOpNotificationSender вы получите консольный sender, и тест начнёт мусорить выводом. Он не сломается по логике, но превратится в noisy-тест, а шумные тесты долго не живут: их начинают игнорировать.
3. @TestPropertySource: overrides свойств
Профили решают вопрос “какие бины вообще участвуют в сборке”. Но часто этого недостаточно: даже в test профиле вам нужно точечно подкрутить настройки. Например, вы хотите, чтобы отчёты писались в build/test-reports, а не в общий build/reports, чтобы тесты не мешались с вашими ручными запусками. Или вы хотите уменьшить лимит чего-нибудь, чтобы тест проходил быстрее (в нашем проекте лимитов не много, но в реальных приложениях это классика).
Для этого существует @TestPropertySource. Она добавляет property sources в Environment именно для тестового контекста. Самое удобное, особенно для начинающих, — inline overrides через properties = .... Они читаются прямо в тесте, не требуют отдельного файла, и очень явно показывают намерение.
Вот минимальный пример: мы не тестируем “генерацию отчёта”, мы просто показываем, что свойство переопределилось именно в окружении теста:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
// Поднимаем контекст из "боевой" конфигурации, но добавляем тестовый слой свойств
@SpringJUnitConfig(classes = com.example.contextflow.config.core.ContextFlowAppConfig.class)
// Inline override: читается прямо из теста и явно показывает намерение
@TestPropertySource(properties = "contextflow.report.output-dir=build/test-reports")
class PropertyOverrideTest {
// Берём Environment как самый честный способ посмотреть итоговые свойства
@Autowired
Environment environment;
@Test
void overridesReportOutputDir() {
// Проверяем не поведение бина, а именно итоговое значение свойства в окружении
String dir = environment.getProperty("contextflow.report.output-dir");
assertEquals("build/test-reports", dir);
}
}
Обратите внимание на “уровень ожиданий” в этом тесте. Мы не проверяем, что каталог реально создан и что туда записался файл. Это уже сценарная проверка поведения (и мы сознательно оставим её следующей лекции). Здесь мы фиксируем только факт: в тестовом контексте значение свойства такое-то. Это очень полезно как быстрая диагностика: если позже тесты начали писать не туда, вы быстро поймёте, на уровне properties что-то сломалось.
Кроме inline overrides, @TestPropertySource умеет подключать test property file. Это удобно, если у вас много тестов и вы не хотите копировать одну и ту же строку по всем классам. Например, можно создать src/test/resources/contextflow-test-overrides.properties и подключить его:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringJUnitConfig(classes = com.example.contextflow.config.core.ContextFlowAppConfig.class)
// Подключаем отдельный файл с тестовыми overrides (лежит в src/test/resources)
@TestPropertySource(locations = "/contextflow-test-overrides.properties")
class PropertyFileOverrideTest {
@Test
void contextStartsWithExtraTestProperties() {
// Smoke-проверка: файл найден и контекст стартует с дополнительными свойствами
assertTrue(true);
}
}
Этот тест выглядит “пустым”, и это нормально как учебный пример: он демонстрирует механизм. В реальном проекте вы либо проверите конкретное свойство, либо используете этот property file как базу для сценарных тестов. Но даже “пустой” тест иногда полезен как smoke на наличие файла и корректность путей.
Приоритеты свойств
Свойства в Spring — это как слои одежды: чем ближе к телу, тем труднее игнорировать. Но для этой лекции полезнее не заучивать весь глобальный граф precedence Spring, а держать в голове три рабочих правила.
- Обычные свойства приложения, включая profile-specific значения, дают baseline.
- @TestPropertySource(locations = ...) накладывает сверху тестовый file-based слой.
- @TestPropertySource(properties = ...) перекрывает значения из file-based test source и удобен для точечных overrides рядом с тестом.
Поэтому inline overrides так удобны: самый локальный и читаемый тестовый слой живёт прямо в коде теста.
flowchart TD
A["Обычные свойства приложения
(включая profile-specific)"] --> B["@TestPropertySource(locations = ...)"]
B --> C["@TestPropertySource(properties = ...)"]
System properties и переменные окружения тоже могут участвовать в разрешении свойств, но здесь сознательно не делаем их повседневным рычагом. Как только управление тестом уезжает во внешнее окружение, диагностика быстро становится мутной: значение вроде бы «пришло откуда-то», а рядом с тестом этого уже не видно.
4. Test-specific @Configuration: тестовые бины
Иногда профилей и свойств всё равно мало. Причина простая: есть зависимости, которые в “настоящем” приложении делают побочные эффекты, а в тестах они только мешают. Например, NotificationSender может писать в консоль (и вы будете видеть мусор в выводе тестов). Или AuditWriter может писать в файл. Или какой-то инфраструктурный бин читает ресурсы, и вам в тесте хочется заменить его на упрощённый.
В Boot-мире часто показывают @MockBean. Но мы в курсе Spring Core, и наша цель — понимать контейнер, а не прятать wiring за “волшебной аннотацией”. Поэтому мы делаем честнее: добавляем дополнительную тестовую конфигурацию и регистрируем в ней stub или no-op реализацию.
Обычно это выглядит так: в тесте есть маленький @Configuration класс (часто как static inner class), который объявляет тестовые бины. Затем тест поднимает контекст из двух конфигураций: production ContextFlowAppConfig и test overrides config.
Пример: заменим NotificationSender на “тишину” — no-op. А чтобы контейнер выбрал его при автосвязывании, отметим как @Primary.
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
class NoOpNotificationTestConfig {
@Bean
@Primary // В тестах этот бин должен "победить" любые production-реализации
NotificationSender notificationSender() {
// No-op реализация: убираем побочные эффекты (консоль/файлы/сеть) из тестов
return message -> { /* intentionally no-op */ };
}
}
Выглядит смешно, но это полезная “педагогическая простота”. У вас есть порт NotificationSender, и в тестах вы даёте ему реализацию, которая ничего не делает. Тесты становятся тише, а главное — предсказуемее: никаких “случайных” файлов, консоли, сетевых вызовов и других сюрпризов.
Подключается эта конфигурация так:
import com.example.contextflow.domain.ports.NotificationSender;
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(classes = {
com.example.contextflow.config.core.ContextFlowAppConfig.class,
NoOpNotificationTestConfig.class // Подмешиваем тестовую конфигурацию с подменами
})
class TestSpecificConfigWiringTest {
// Инжектим порт: хотим убедиться, что контекст собрался и бин найден
@Autowired
NotificationSender notificationSender;
@Test
void testContextUsesNoOpNotificationSender() {
// Минимальная проверка "контекст стартует и зависимость есть"
assertNotNull(notificationSender);
}
}
Этот пример не проверяет “что это точно no-op”, он показывает принцип: тестовый контекст можно собрать как “боевой плюс накладка”. В реальном тесте вы можете дополнительно проверить тип или поведение, но главное — сам механизм. И да, здесь мы используем @Autowired в тесте через поле, и это нормально: тестовый класс — не production-код, тут цель не иммутабельность, а удобство доступа к бинам контекста. Просто не переносите эту привычку в сервисы приложения — они за такое обижаются и начинают жить тайной жизнью.
5. Stub beans: подмены для управляемости
Stub — это не страшное слово. Это не “mock-фреймворк”, не магия, не алхимия. Stub — это просто простая реализация интерфейса, которая ведёт себя предсказуемо и часто помогает либо убрать шум, либо зафиксировать факт вызова. В нашем курсе мы сознательно делаем ставку на такие простые подмены, потому что они учат думать архитектурно: “у меня есть порт, значит я могу подменить реализацию”.
Пусть у нас есть NotificationSender, и мы хотим, чтобы тестовый sender ничего не печатал, но при этом позволял понять “какой sender в тесте реально используется”. Сделаем stub класс, который запоминает последнее сообщение. Это уже похоже на подготовку к проверкам поведения, но в этой лекции мы будем использовать его именно как инструмент “контролируемого окружения”, а не как полноценный сценарный тест.
import com.example.contextflow.domain.ports.NotificationSender;
class StubNotificationSender implements NotificationSender {
// Храним последнее сообщение, чтобы тест мог "заглянуть внутрь" и проверить факт отправки
private String lastMessage;
@Override
public void send(String message) {
// Никаких side effects: только запоминаем входные данные
lastMessage = message;
}
String getLastMessage() {
// Удобный геттер для тестов
return lastMessage;
}
}
Теперь этот stub можно зарегистрировать через test-specific config:
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
class StubNotificationConfig {
@Bean
@Primary // Этот stub должен быть основным кандидатом для NotificationSender в тестовом контексте
StubNotificationSender notificationSender() {
// Одного bean-а достаточно: его можно внедрять и как порт, и как конкретный stub
return new StubNotificationSender();
}
}
Этого одного bean-а достаточно. Контейнер сможет выдать его и как NotificationSender, и как StubNotificationSender, потому что concrete class реализует нужный порт. Двойная регистрация тут только запутала бы картину.
Если вы сейчас подумали: “подождите, но это уже похоже на проверку поведения!” — вы правы. Поэтому в лекции 4 мы ограничимся тем, что stub помогает сделать окружение контролируемым (мы знаем, что не будет реальных side effects), а полноценные проверки сценария (создали заказ → улетело событие → сработал listener) оставим следующей лекции, где это будет главным учебным вопросом.
Главная дисциплина для stub-ов такая: они должны быть глупыми и предсказуемыми. Если вы написали stub, который внутри парсит JSON, открывает файлы и ещё выбирает реализацию по профилю… поздравляю, вы случайно написали мини-приложение внутри теста. Такое обычно плохо заканчивается.
6. Собираем тестовый контекст ContextFlow
Когда эти три механизма складываются вместе, получается очень удобный “тестовый режим сборки” — вы в одном месте фиксируете: какой профиль, какие свойства и какие зависимости подменены. В итоге тест не зависит от вашей машины и не требует “правильного настроения” у окружения.
Давайте соберём пример “управляемого” тестового контекста, который делает три вещи одновременно: включает test профиль, переопределяет путь вывода отчётов и отключает уведомления через stub/no-op.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringJUnitConfig(classes = {
com.example.contextflow.config.core.ContextFlowAppConfig.class,
NoOpNotificationTestConfig.class // Подмена, чтобы тесты были "тихими"
})
@ActiveProfiles("test") // Фиксируем test-профиль: детерминированность и минимум side effects
@TestPropertySource(properties = "contextflow.report.output-dir=build/test-reports") // Пишем отчёты в отдельную папку
class ManagedEnvironmentSmokeTest {
@Test
void contextStartsInPredictableTestMode() {
// Smoke: шаблон окружения корректно собирается
assertTrue(true);
}
}
Снова “пустой” тест? Да. И это нормально в учебном формате: он показывает “шаблон” для большинства контекстных тестов. Как только вы начнёте писать реальные проверки, вы будете добавлять @Autowired нужных бинов и делать assertions по ним. Но шаблон окружения останется таким же: профиль + свойства + тестовые бины.
Если вы захотите сделать этот тест чуть более осмысленным, но всё ещё не уходя в полноценный сценарий, можно добавить две мягкие проверки: что профиль активен и что property override применился. Это проверка окружения, а не бизнес-логики:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringJUnitConfig(classes = com.example.contextflow.config.core.ContextFlowAppConfig.class)
@ActiveProfiles("test") // Хотим тестовый режим сборки
@TestPropertySource(properties = "contextflow.report.output-dir=build/test-reports") // И отдельный output-dir для отчётов
class EnvironmentSanityTest {
// Проверяем окружение через Environment: это "истина" по профилям и свойствам
@Autowired
Environment environment;
@Test
void profileAndPropertyAreApplied() {
// Профиль действительно активен
assertTrue(environment.acceptsProfiles("test"));
// И свойство реально переопределилось
assertEquals("build/test-reports",
environment.getProperty("contextflow.report.output-dir"));
}
}
Такие проверки часто становятся “страховочной сеткой”. Если через месяц вы поменяете конфигурацию и случайно сломаете загрузку property sources, этот тест упадёт быстро и понятно: не где-то глубоко в бизнес-сценарии, а прямо на уровне “окружение не то”.
7. Типичные ошибки при управлении тестовым окружением
Когда начинаешь активно использовать @ActiveProfiles, @TestPropertySource и stub beans, почти всегда наступает момент “почему оно не работает, я же точно написал аннотацию”. Это нормальная стадия развития, примерно как “почему git rebase всё сломал” — через неё проходят все, кто живёт в реальном мире. Важно не выучить магические заклинания, а понимать причины.
Ошибка №1: тесты запускаются не в test профиле, а “как получится”.
Если вы полагаетесь на “профиль по умолчанию” или на то, что IDE “как-то выставит spring.profiles.active”, вы почти гарантированно получите непредсказуемость. Сегодня тест запустился в dev, завтра — вообще без профиля, а послезавтра на CI активировался другой набор. Самый простой фикс — явно ставить @ActiveProfiles("test") там, где тесту нужен именно test-режим сборки.
Ошибка №2: свойства переопределили, но вы проверяете не то место.
Новички иногда делают @TestPropertySource(...), а потом проверяют какое-то поле внутри bean-а и удивляются, что оно “старое”. Часто причина банальна: либо bean создаётся не из того свойства (ключ другой), либо значение в bean-е вычисляется сложнее, чем кажется. Для диагностики сначала проверяйте Environment.getProperty(key) — это самый честный “источник истины” для property values, а уже потом проверяйте поведение bean-а.
Ошибка №3: вы переопределили свойство, но не понимаете, что его перекрывает другое.
Если в тесте вы подключили property file через @TestPropertySource(locations=...), а затем ещё где-то добавили inline @TestPropertySource(properties=...), легко забыть, кто кого перекрыл. Старайтесь держать overrides компактными и близкими к тесту, а если overrides много — делайте один “общий” файл для тестового пакета и в конкретных тестах добавляйте только точечные inline изменения.
Ошибка №4: stub bean добавили, а контейнер всё равно выбирает “боевой” бин.
Это классика, когда в контексте остаются две реализации интерфейса, и autowiring выбирает не ту. Чаще всего вы забыли @Primary на stub-е, или у вас в production коде инъекция через @Qualifier, который указывает на конкретное имя. Здесь важно помнить: stub не должен быть “каким-то ещё одним кандидатом”, он должен быть выбранным кандидатом. Это достигается либо @Primary, либо согласованным @Qualifier, либо тем, что вы поднимаете для теста узкий контекст, где нет “боевого” кандидата.
Ошибка №5: test overrides начинают превращаться в отдельную архитектуру.
Когда тестовых конфигураций становится слишком много и они начинают дублировать половину production wiring, вы попадаете в ловушку: тесты перестают проверять реальное приложение и начинают проверять “тестовую версию приложения”. Хорошая эвристика такая: тестовый контекст должен быть максимально похож на production, но с минимальными подменами ради детерминированности и контроля side effects. Если вы переписали половину конфигурации — вы уже тестируете другой мир.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ