1. Сценарные тесты в Spring
Сценарный тест — это момент, когда вы перестаёте спрашивать “контекст жив?” и начинаете спрашивать “приложение делает то, что обещало?”. В Spring Core проекте это особенно важно, потому что часть поведения “распределена” по контейнеру: профили меняют набор бинов, свойства меняют настройки, а события и listeners выносят побочные эффекты из основного use-case. И вот это всё в сумме без контекста не проверяется честно.
Контекст мы уже умеем поднимать, профили и свойства — фиксировать, шумные зависимости — подменять. Теперь на этой базе можно перейти к следующему вопросу: не просто стартует ли приложение, а проходит ли реальный use-case через события и listeners так, как мы ожидаем.
Сценарный тест не должен становиться “тестом всего на свете”. Он по-прежнему держит фокус, просто фокус теперь шире, чем один класс: обычно это один use-case (например, создание заказа), плюс проверка пары наблюдаемых результатов. Если сделать его слишком жирным, он превратится в плохо управляемую “сагу на 400 строк”, где падение будет похоже на письмо от бывшего: “всё сложно, ничего не понятно”.
Чтобы не путать жанры, удобно держать в голове короткую табличку:
| Тип теста | Главный вопрос | Контекст Spring нужен? | Типичный результат |
|---|---|---|---|
| Unit test | “Логика этого класса работает?” | нет | проверка метода и stub’ов |
| Smoke test | “Контекст стартует и wiring не сломан?” | да | ctx поднялся, ключевые бины доступны |
| Scenario-level test | “Сценарий приложения работает вместе с events/listeners?” | да | меняется состояние + срабатывают side effects |
2. Наблюдаемые результаты в ContextFlow
В сквозном проекте ContextFlow у нас есть важная архитектурная идея: use-case сервис публикует событие, а побочные эффекты (audit/notification/statistics) живут в listeners. Это делает код чище, но добавляет новый риск: вы можете случайно “оторвать” listener, сломать профили, переименовать бин, и приложение тихо перестанет выполнять часть работы — при этом основной сервис может продолжать “успешно” возвращать результат.
Поэтому в сценарном тесте нам особенно важно проверять не только “заказ создался”, но и “event flow действительно прошёл”. При этом мы не хотим проверять это по шумным признакам вроде System.out.println(...), потому что консоль — это плохой свидетель: она всё видела, но ничего не докажет.
Мы будем опираться на наблюдаемые результаты, которые легко проверить в памяти:
- состояние in-memory хранилища (заказ сохранён, статус изменён);
- запись в “записывающий” audit writer (в тесте — recording stub);
- отправка уведомления (в тесте — recording stub);
- факт обработки события (в тесте — простой collector listener).
И важный момент: у нас события синхронные по умолчанию, поэтому после возврата из placeOrder() мы уже можем делать assertions. Это редкий случай, когда “всё произошло сразу” — и да, наслаждайтесь этим моментом, пока мы не ушли в асинхронность (но сегодня мы туда не уходим).
3. Recording stubs для side effects
Чтобы сценарный тест был тихим и доказательным, нам уже недостаточно просто заглушить побочный эффект. Нужны те же управляемые подмены, но теперь они должны ещё и сохранять следы своей работы. Поэтому вместо no-op реализаций берём recording stubs: они ничего не печатают и не пишут в файл, а просто складывают данные в список. Это очень похоже на идею “чёрного ящика”: нас интересует факт вызова и параметры, а не то, как красиво оно улетело в консоль.
Начнём с уведомлений. Предположим, в проекте есть порт:
package com.example.contextflow.domain.ports;
public interface NotificationSender {
// Важно: порт не знает, КАК отправлять уведомления — только ЧТО нужно отправить.
void send(String message);
}
Тогда recording stub может выглядеть так:
package com.example.contextflow.testdoubles;
import java.util.ArrayList;
import java.util.List;
public class RecordingNotificationSender implements NotificationSender {
// Здесь мы "запоминаем" все отправленные сообщения, чтобы потом проверить их в тесте.
private final List<String> messages = new ArrayList<>();
@Override
public void send(String message) {
// Никакой отправки наружу: только фиксация факта вызова и параметров.
messages.add(message);
}
public List<String> messages() {
// Возвращаем копию, чтобы тесты не могли случайно мутировать внутреннее состояние.
return List.copyOf(messages);
}
}
Обратите внимание на стиль: поведение предсказуемое, API маленькое, никаких “умных” правил. Stub должен стабилизировать тест, а не подменить собой половину приложения.
То же самое сделаем для аудита. Пусть в проекте есть:
package com.example.contextflow.domain.ports;
import com.example.contextflow.domain.model.AuditRecord;
public interface AuditWriter {
// Порт для аудита: бизнес-код сообщает "вот запись", а реализация решает, куда писать.
void write(AuditRecord record);
}
Тогда recording audit writer:
package com.example.contextflow.testdoubles;
import com.example.contextflow.domain.model.AuditRecord;
import java.util.ArrayList;
import java.util.List;
public class RecordingAuditWriter implements AuditWriter {
// Список записей аудита, которые были "написаны" во время сценария.
private final List<AuditRecord> records = new ArrayList<>();
@Override
public void write(AuditRecord record) {
// В тесте мы не пишем на диск/в БД: просто фиксируем, что запись была.
records.add(record);
}
public List<AuditRecord> records() {
// Защищаем внутреннее состояние от внешней модификации.
return List.copyOf(records);
}
}
С такими дублёрами мы сможем в тесте сказать: “после сценария в records() должна появиться запись”, и это будет куда убедительнее, чем “ну я видел в консоли строчку”.
4. Тестовая конфигурация со stub beans
Теперь соберём всё это в тестовый ApplicationContext. Механика уже знакома: берём боевой ContextFlowAppConfig и поверх него добавляем маленький тестовый модуль. Новое здесь не в самом @Configuration, а в том, что подмены теперь не просто гасят шум, а делают event flow наблюдаемым.
package com.example.contextflow.scenario;
import com.example.contextflow.testdoubles.RecordingAuditWriter;
import com.example.contextflow.testdoubles.RecordingNotificationSender;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
class ScenarioTestDoublesConfig {
@Bean
@Primary // В тестах хотим, чтобы по умолчанию брался именно recording stub.
RecordingNotificationSender notificationSender() {
return new RecordingNotificationSender();
}
@Bean
@Primary // Аналогично: аудит в тесте "записываем" в память.
RecordingAuditWriter auditWriter() {
return new RecordingAuditWriter();
}
}
Здесь @Primary нужен только затем, чтобы контейнер по интерфейсам выбрал recording-реализации по умолчанию.
Теперь добавим самый простой способ наблюдать event flow: collector listener. Он не делает бизнес-действий, только считает события.
package com.example.contextflow.scenario;
import com.example.contextflow.domain.events.OrderCreatedEvent;
import com.example.contextflow.domain.events.OrderCancelledEvent;
import org.springframework.context.event.EventListener;
class TestOrderEventsCollector {
// Счётчики — это "наблюдаемое состояние", которое легко проверять в тесте.
int created;
int cancelled;
@EventListener
void on(OrderCreatedEvent e) {
// Фиксируем факт, что событие создания реально дошло до listener'а.
created++;
}
@EventListener
void on(OrderCancelledEvent e) {
// Аналогично для отмены.
cancelled++;
}
}
И зарегистрируем его в тестовом конфиге (отдельным маленьким бином, чтобы Spring мог внедрить его в тест):
package com.example.contextflow.scenario;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ScenarioTestCollectorsConfig {
@Bean
TestOrderEventsCollector collector() {
// Делаем collector бином, чтобы его можно было @Autowired в тестовый класс.
return new TestOrderEventsCollector();
}
}
Да, это выглядит как “лишняя деталь”, но на практике collector — это самый спокойный способ проверить события. Он не зависит от файлов, консоли, локали и уровня логирования. Он просто считает — как бухгалтер, только без печати “Сдано”.
5. Сценарий создания заказа
Теперь соберём сам тест. Важно, чтобы он был читаем как история. Хороший сценарный тест — это такой, который вы можете показать человеку, который “плохо разбирается в программировании”, и он всё равно поймёт логику: подготовили данные, вызвали сценарий, проверили результаты.
Скелет теста:
package com.example.contextflow.scenario;
import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.ContextFlowAppConfig;
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.*;
@SpringJUnitConfig({ContextFlowAppConfig.class, ScenarioTestDoublesConfig.class, ScenarioTestCollectorsConfig.class})
@ActiveProfiles("test")
class CreateOrderScenarioTest {
@Autowired OrderPlacementService orderPlacementService;
@Autowired TestOrderEventsCollector collector;
@Test
void createOrder_publishesEvent_andRunsListeners() {
// тест — ниже, отдельным блоком
// (в примерах ниже мы показываем, что именно здесь обычно происходит)
}
}
Здесь мы поднимаем конфигурацию приложения (ContextFlowAppConfig) и добавляем к ней два тестовых модуля. @ActiveProfiles("test") включает ваш детерминированный режим (например, deterministic id generator и no-op/упрощённые реализации там, где вы это предусмотрели профилем).
Теперь сам тестовый метод. Я намеренно оставлю его коротким: мы проверяем одну историю — “создали заказ”.
import com.example.contextflow.domain.model.CreateOrderCommand;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void createOrder_publishesEvent_andRunsListeners() {
// Given: готовим входные данные сценария
var cmd = CreateOrderCommand.sample(); // допустим, у вас есть test-helper
// When: запускаем use-case
var order = orderPlacementService.placeOrder(cmd);
// Then: проверяем наблюдаемые результаты
assertNotNull(order);
assertEquals(1, collector.created);
}
В реальном проекте вам, конечно, нужно будет создать команду без “магии”. Если sample() вам не нравится (и это нормально), можно собрать команду руками. Пример в стиле “минимально жизнеспособно”, без лишней романтики:
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void createOrder_savesOrder_andPublishesEvent() {
// Given
var cmd = new CreateOrderCommand("customer-1", List.of("item-1"));
// When
var order = orderPlacementService.placeOrder(cmd);
// Then
assertNotNull(order.id());
assertEquals(1, collector.created);
}
Да, это упрощённая команда (в вашем проекте она богаче), но идея важнее формы: сценарный тест должен быть понятным.
Теперь добавим проверку side effects через наши recording stubs. Для этого внедрим их в тест и проверим, что listeners действительно “сходили” в свои порты.
import com.example.contextflow.testdoubles.RecordingAuditWriter;
import com.example.contextflow.testdoubles.RecordingNotificationSender;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Autowired RecordingAuditWriter audit;
@Autowired RecordingNotificationSender notifications;
@Test
void createOrder_triggersAuditAndNotification() {
// Given
var cmd = new CreateOrderCommand("customer-1", List.of("item-1"));
// When
orderPlacementService.placeOrder(cmd);
// Then: проверяем side effects как наблюдаемый результат
assertEquals(1, audit.records().size());
assertEquals(1, notifications.messages().size());
}
Этот тест — уже настоящая проверка event-driven архитектуры. Он не просто “создал заказ”, он доказал, что side effects произошли через контейнерно-собранные listeners.
Чтобы зафиксировать связь в голове, полезно один раз увидеть это как последовательность:
sequenceDiagram
participant T as Test
participant S as OrderPlacementService
participant P as ApplicationEventPublisher
participant L1 as AuditOrderEventsListener
participant L2 as NotificationOrderEventsListener
participant A as RecordingAuditWriter
participant N as RecordingNotificationSender
T->>S: "placeOrder(cmd)"
S->>P: "publishEvent(OrderCreatedEvent)"
P->>L1: "on(OrderCreatedEvent)"
L1->>A: "write(record)"
P->>L2: "on(OrderCreatedEvent)"
L2->>N: "send(message)"
Если где-то отвалится listener, или profile внезапно сменит реализацию, или в wiring появится неоднозначность — этот тест начнёт падать. И это хорошо: лучше пусть тест ругается, чем прод-окружение молча перестаёт отправлять уведомления.
6. Сценарий отмены заказа
Отмена заказа — отличный второй сценарий для теста, потому что он проверяет другую ветку: другой use-case, другое событие, другой набор side effects. И очень важно, что это отдельный тест. Не надо проверять “создание и отмену и отчёт и всё-всё-всё” в одном методе. Один тест — один понятный сюжет.
Скелет: создаём заказ, отменяем, проверяем статус и события.
import com.example.contextflow.application.service.OrderCancellationService;
import com.example.contextflow.domain.model.CancelOrderCommand;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Autowired OrderCancellationService orderCancellationService;
@Test
void cancelOrder_changesStatus_andPublishesEvent() {
// Given: сначала нужен созданный заказ
var created = orderPlacementService.placeOrder(new CreateOrderCommand("customer-1", List.of("item-1")));
// When: отменяем
orderCancellationService.cancel(new CancelOrderCommand(created.id()));
// Then: проверяем, что событие отмены реально прошло через event flow
assertEquals(1, collector.cancelled);
}
Если у вас cancel() возвращает обновлённый заказ — отлично, тогда можно проверить статус напрямую:
import com.example.contextflow.domain.model.OrderStatus;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void cancelOrder_setsCancelledStatus() {
// Given
var created = orderPlacementService.placeOrder(new CreateOrderCommand("customer-1", List.of("item-1")));
// When
var cancelled = orderCancellationService.cancel(new CancelOrderCommand(created.id()));
// Then
assertEquals(OrderStatus.CANCELLED, cancelled.status());
assertEquals(1, collector.cancelled);
}
А теперь добавим side effects. После отмены мы ожидаем, что listener’ы снова отработают: будет аудит, будет уведомление (или хотя бы попытка уведомления — в зависимости от вашей бизнес-логики). В тесте мы проверяем факты через recording stubs.
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void cancelOrder_triggersSideEffects() {
// Given
var created = orderPlacementService.placeOrder(new CreateOrderCommand("customer-1", List.of("item-1")));
// When
orderCancellationService.cancel(new CancelOrderCommand(created.id()));
// Then
assertEquals(1, collector.cancelled);
assertEquals(2, audit.records().size()); // 1 за create + 1 за cancel
assertEquals(2, notifications.messages().size()); // аналогично
}
Да, здесь мы завязались на то, что тест выполняет и create, и cancel. Это нормально, если вы воспринимаете этот тест как “сценарий отмены” (который требует предварительного создания). Главное — не превращать это в длинный сериал. Как только вы видите, что тест “разрастается”, лучше выделить подготовку в helper-метод внутри тестового класса, чтобы сюжет не терялся.
Файловые артефакты в build/
Иногда сценарий в ContextFlow заканчивается не только событиями, но и артефактом: например, отчётом, который записывается в build/. В тестах это можно проверять вполне честно, но важно не сделать два типичных промаха: писать в случайные директории и проверять содержимое “на глаз”.
Пусть у вас есть ReportingService, и он пишет файл в директорию из свойства contextflow.report.output-dir. В тесте мы можем задать этот путь явно через @TestPropertySource(...), чтобы всё складывалось в “тестовый карман” внутри build/.
import org.springframework.test.context.TestPropertySource;
@TestPropertySource(properties = "contextflow.report.output-dir=build/test-reports")
class ReportScenarioTest {
// ...
}
А затем проверять факт появления файла через Files.exists(...). Минимальный пример:
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void generatesReportFileInBuildDir() {
// When: генерируем отчёт (побочный эффект сценария)
reportingService.generateDailyReport();
// Then: проверяем наблюдаемый результат — файл появился в ожидаемом месте
assertTrue(Files.exists(Path.of("build/test-reports/daily-report.txt")));
}
Это не идеальный тест (идеальный — ещё и проверит хотя бы заголовок), но он показывает важную дисциплину: тесты пишут артефакты туда, где им разрешено, и проверяют наблюдаемый результат без участия человека. И да, build/ — это как песочница для артефактов: можно копать, строить замки, а потом всё спокойно снести одной командой clean().
7. Типичные ошибки в сценарных тестах
Ошибка №1: превращать сценарный тест в “проверку всего приложения”.
Это обычно начинается невинно: “ну раз контекст поднялся, давайте проверим ещё вот это… и ещё вот это… и отчёт… и legacy XML…”. В итоге тест становится длинным, хрупким и непонятным, а падение не даёт вам ясного ответа. Лучше держать один тест — один сценарий, и проверять 2–4 ключевых наблюдаемых результата.
Ошибка №2: проверять события через System.out или случайные побочные эффекты.
Консольный вывод — самый нечестный свидетель в суде тестирования. Сегодня он есть, завтра его убрали, послезавтра поменяли формат строки. Гораздо надёжнее использовать recording stub (список записей) или collector-bean, который фиксирует факт обработки события. Тогда assertion не зависит от “красоты логов”.
Ошибка №3: забывать, что in-memory бины — это singleton’ы, и они сохраняют состояние между тестами.
Spring Test по умолчанию кэширует контекст, чтобы тесты работали быстрее. Это прекрасно, пока ваш InMemoryOrderStore не начал копить заказы от прошлого теста. Результат — тесты, которые падают только “после обеда” или только “если запускать весь пакет”. Лечится это либо явной очисткой состояния в @BeforeEach, либо использованием тестовых дублёров, либо тем, что store в тесте создаётся заново в отдельной конфигурации.
Ошибка №4: не фиксировать @ActiveProfiles("test") и получать случайные/непредсказуемые значения.
Если вы забыли активировать test профиль, может внезапно включиться UuidOrderIdGenerator, и вы уже не можете проверить ни конкретный id, ни имя файла, ни стабильный формат. Потом тест превращается в “assertNotNull на всё подряд”, а это уже не тест, а акт отчаяния. Профиль в контекстных тестах — часть контракта теста, его надо явно указывать.
Ошибка №5: переопределять слишком много свойств и бинов сразу, превращая тест в альтернативную конфигурацию приложения.
Иногда хочется “для теста” заменить половину проекта: и генератор id, и store, и аудит, и уведомления, и ещё три форматтера. Потом вы уже тестируете не ContextFlow, а “контекст, который вы случайно собрали в тесте”. Переопределяйте ровно то, что делает сценарий наблюдаемым и детерминированным: обычно это side-effect порты и пути в build/.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ