JavaRush /Курсы /Spring Core /Управляемое тестовое окружение

Управляемое тестовое окружение

Spring Core
24 уровень , 3 лекция
Открыта

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, а держать в голове три рабочих правила.

  1. Обычные свойства приложения, включая profile-specific значения, дают baseline.
  2. @TestPropertySource(locations = ...) накладывает сверху тестовый file-based слой.
  3. @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. Если вы переписали половину конфигурации — вы уже тестируете другой мир.

1
Задача
Spring Core, 24 уровень, 3 лекция
Недоступна
Тестовый профиль и override свойства
Тестовый профиль и override свойства
1
Задача
Spring Core, 24 уровень, 3 лекция
Недоступна
Test-specific `@Configuration` и stub bean
Test-specific `@Configuration` и stub bean
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ