Одна из основных функций Spring - функция публикации событий. Мы можем использовать события для разделения частей нашего приложения и реализации шаблона публикации-подписки. Одна часть нашего приложения может публиковать событие, на которое реагируют несколько слушателей (даже асинхронно). В рамках Spring Framework 5.3.3 (Spring Boot 2.4.2) теперь мы можем записывать и проверять все опубликованные события ( ApplicationEvent) при тестировании приложений Spring Boot с использованием @RecrodApplicationEvents.

Настройка для записи ApplicationEvent с помощью Spring Boot

Чтобы использовать эту функцию, нам нужен только Spring Boot Starter Test, который является частью каждого проекта Spring Boot, который вы загружаете на start.spring.io .

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

Обязательно используйте версию Spring Boot >= 2.4.2, так как нам нужна версия Spring Framework >= 5.3.3.

Для наших тестов есть одно дополнительное требование: нам нужно работать со SpringTestContext поскольку публикация событий является основной функциональностью платформы ApplicationContext.

Следовательно, она не работает для модульного теста, где не используется поддержка инфраструктуры Spring TestContext. Есть несколько аннотаций тестовых срезов Spring Boot, которые удобно загружают контекст для нашего теста.

Введение в публикацию событий Spring

В качестве примера мы протестируем класс Java, который выдает UserCreationEvent, когда мы успешно создаем нового пользователя. Событие включает метаданные о пользователе, актуальные для последующих задач:

public class UserCreationEvent extends ApplicationEvent {
 
  private final String username;
  private final Long id;
 
  public UserCreationEvent(Object source, String username, Long id) {
    super(source);
    this.username = username;
    this.id = id;
  }
 
  // getters
}

Начиная со Spring Framework 4.2, нам не нужно расширять абстрактный класс ApplicationEvent и мы можем использовать любой POJO в качестве нашего класса событий. В следующий статье привелено отличное введение в события приложений с помощью Spring Boot.

Наш UserService создает и хранит наших новых пользователей. Мы можем создать как одного пользователя, так и группу пользователей:

@Service
public class UserService {
 
  private final ApplicationEventPublisher eventPublisher;
 
  public UserService(ApplicationEventPublisher eventPublisher) {
    this.eventPublisher = eventPublisher;
  }
 
  public Long createUser(String username) {
    // logic to create a user and store it in a database
    Long primaryKey = ThreadLocalRandom.current().nextLong(1, 1000);
 
    this.eventPublisher.publishEvent(new UserCreationEvent(this, username, primaryKey));
 
    return primaryKey;
  }
 
  public List<Long> createUser(List<String> usernames) {
    List<Long> resultIds = new ArrayList<>();
 
    for (String username : usernames) {
      resultIds.add(createUser(username));
    }
 
    return resultIds;
  }
}

Как только пользователь станет частью нашей системы, мы уведомим другие компоненты нашего приложения, опубликовав файл UserCreationEvent.

Например, наше приложение выполняет две дополнительные операции всякий раз, когда мы запускаем такое UserCreationEvent:

@Component
public class ReportingListener {
 
  @EventListener(UserCreationEvent.class)
  public void reportUserCreation(UserCreationEvent event) {
    // e.g. increment a counter to report the total amount of new users
    System.out.println("Increment counter as new user was created: " + event);
  }
 
  @EventListener(UserCreationEvent.class)
  public void syncUserToExternalSystem(UserCreationEvent event) {
    // e.g. send a message to a messaging queue to inform other systems
    System.out.println("informing other systems about new user: " + event);
  }
}

Запись и проверка событий приложения с помощью Spring Boot

Давайте напишем наш первый тест, который гарантирует, что UserService генерирует событие всякий раз, когда мы создаем нового пользователя. Мы инструктируем Spring фиксировать наши события с помощью @RecordApplicationEvents аннотации поверх нашего тестового класса:

@SpringBootTest
@RecordApplicationEvents
class UserServiceFullContextTest {
 
  @Autowired
  private ApplicationEvents applicationEvents;
 
  @Autowired
  private UserService userService;
 
  @Test
  void userCreationShouldPublishEvent() {
 
    this.userService.createUser("duke");
 
    assertEquals(1, applicationEvents
      .stream(UserCreationEvent.class)
      .filter(event -> event.getUsername().equals("duke"))
      .count());
 
    // There are multiple events recorded
    // PrepareInstanceEvent
    // BeforeTestMethodEvent
    // BeforeTestExecutionEvent
    // UserCreationEvent
    applicationEvents.stream().forEach(System.out::println);
  }
}

После того, как мы выполняем публичный метод нашего класса испытываемый (createUser из UserService в этом примере), мы можем запросить все захваченные события из бинов ApplicationEvents, которые мы внедряем в наш тест.

Открытый .stream()метод класса ApplicationEvents позволяет просмотреть все события, записанные для теста. Есть перегруженная версия .stream(), в которой мы запрашиваем поток только определенных событий.

Несмотря на то, что мы генерируем только одно событие из нашего приложения, Spring захватывает четыре события для теста выше. Остальные три события относятся к Spring, как и PrepareInstanceEvent в среде TestContext.

Поскольку мы используем JUnit Jupiter и SpringExtension (зарегистрированный для нас при использовании @SpringBootTest), мы также можем внедрить bean-компонент ApplicationEvents в метод жизненного цикла JUnit или непосредственно в тест:

@Test
void batchUserCreationShouldPublishEvents(@Autowired ApplicationEvents events) {
  List<Long> result = this.userService.createUser(List.of("duke", "mike", "alice"));
 
  assertEquals(3, result.size());
  assertEquals(3, events.stream(UserCreationEvent.class).count());
}

Экземпляр ApplicationEvents создается до и удаляется после каждого теста как часть текущего потока. Следовательно, вы даже можете использовать внедрение поля и @TestInstance(TestInstance.Lifecycle.PER_CLASS)делить тестовый экземпляр между несколькими тестами ( PER_METHOD по умолчанию).

Обратите внимание, что запуск всего контекста Spring @SpringBootTest для такого теста может быть излишним. Мы также могли бы написать тест, который заполняет минимальный Spring TestContext только нашим bean-компонентом UserService, чтобы убедиться, что UserCreationEvent  опубликован:

@RecordApplicationEvents
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = UserService.class)
class UserServicePerClassTest {
 
  @Autowired
  private ApplicationEvents applicationEvents;
 
  @Autowired
  private UserService userService;
 
  @Test
  void userCreationShouldPublishEvent() {
 
    this.userService.createUser("duke");
 
    assertEquals(1, applicationEvents
      .stream(UserCreationEvent.class)
      .filter(event -> event.getUsername().equals("duke"))
      .count());
 
    applicationEvents.stream().forEach(System.out::println);
  }
}

… Или используйте альтернативный подход к тестированию.

Альтернативы тестированию событий Spring

В зависимости от того, чего вы хотите достичь с помощью теста, может быть достаточно проверить эту функциональность с помощью модульного теста:

@ExtendWith(MockitoExtension.class)
class UserServiceUnitTest {
 
  @Mock
  private ApplicationEventPublisher applicationEventPublisher;
 
  @Captor
  private ArgumentCaptor<UserCreationEvent> eventArgumentCaptor;
 
  @InjectMocks
  private UserService userService;
 
  @Test
  void userCreationShouldPublishEvent() {
 
    Long result = this.userService.createUser("duke");
 
    Mockito.verify(applicationEventPublisher).publishEvent(eventArgumentCaptor.capture());
 
    assertEquals("duke", eventArgumentCaptor.getValue().getUsername());
  }
 
  @Test
  void batchUserCreationShouldPublishEvents() {
    List<Long> result = this.userService.createUser(List.of("duke", "mike", "alice"));
 
    Mockito
      .verify(applicationEventPublisher, Mockito.times(3))
      .publishEvent(any(UserCreationEvent.class));
  }
}

Обратите внимание, что здесь мы не используем никакой поддержки Spring Test и полагаемся исключительно на Mockito и JUnit Jupiter.

Другой подход заключается в том, чтобы не проверять события публикации явно, а проверять весь сценарий использования с помощью интеграционного теста:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ApplicationIT {
 
  @Autowired
  private TestRestTemplate testRestTemplate;
 
  @Test
  void shouldCreateUserAndPerformReporting() {
 
    ResponseEntity<Void> result = this.testRestTemplate
      .postForEntity("/api/users", "duke", Void.class);
 
    assertEquals(201, result.getStatusCodeValue());
    assertTrue(result.getHeaders().containsKey("Location"),
      "Response doesn't contain Location header");
 
    // additional assertion to verify the counter was incremented
    // additional assertion that a new message is part of the queue
  }
}

В этом случае нам нужно будет проверить результат работы наших слушателей событий и, например, проверить, что мы помещаем сообщение в очередь или увеличиваем счетчик.

Резюме тестирования событий Spring с помощью Spring Boot

Все различные подходы сводятся к тестированию поведения и состояния. Благодаря новой функции @RecordApplicationEvents в Spring Test у нас может возникнуть соблазн провести больше поведенческих тестов и проверить внутреннюю часть нашей реализации. В общем, мы должны сосредоточиться на тестировании состояния (также известном как результат), поскольку оно поддерживает беспроблемный рефакторинг.

Представьте себе следующее: мы используем, ApplicationEvent чтобы разделять части нашего приложения и гарантировать, что это событие запускается во время теста. Через две недели мы решаем убрать / переработать эту развязку (по каким-то причинам). Наш вариант использования может по-прежнему работать, как ожидалось, но наш тест теперь не проходит, потому что мы делаем предположения о технической реализации, проверяя, сколько событий мы опубликовали.

Помните об этом и не перегружайте свои тесты деталями реализации (если вы хотите провести рефакторинг в будущем :). Тем не менее, есть определенные тестовые сценарии, когда функция @RecordApplicationEvents очень помогает.

Исходный код со всеми альтернативными вариантами для тестирования Spring Event с помощью Spring Boot доступен на GitHub.