diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultAssertablePublishedEvents.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultAssertablePublishedEvents.java index b7afd051..2e4f84b3 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultAssertablePublishedEvents.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultAssertablePublishedEvents.java @@ -64,4 +64,13 @@ class DefaultAssertablePublishedEvents implements AssertablePublishedEvents, App public void onApplicationEvent(ApplicationEvent event) { delegate.onApplicationEvent(event); } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return delegate.toString(); + } } diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultPublishedEvents.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultPublishedEvents.java index e37acf21..70667d30 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultPublishedEvents.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultPublishedEvents.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.context.ApplicationEvent; @@ -79,6 +80,18 @@ class DefaultPublishedEvents implements PublishedEvents, ApplicationListener the type of the event. * @param type must not be {@literal null}. * @return will never be {@literal null}. * @see #forEventOfType(Class) */ public EventResult andWaitForEventOfType(Class type) { - return new EventResult(type, Function.identity()); + return new EventResult(type, Function.identity(), null); } /** @@ -402,6 +406,11 @@ public class Scenario { private record ExecutionResult(S first, T second) {} + /** + * The result of an expected state change. + * + * @author Oliver Drotbohm + */ public class StateChangeResult { private ExecutionResult result; @@ -445,25 +454,48 @@ public class Scenario { events.accept(Scenario.this.events); } + + /** + * Expects an event of the given type to arrive eventually. Use API on the returned {@link EventResult} to specify + * more detailed expectations and conclude those with a call a flavor of {@link EventResult#toArrive()}. + * + * @param the type of the event + * @param eventType must not be {@literal null}. + * @return will never be {@literal null}. + */ + public EventResult andExpect(Class eventType) { + return new EventResult<>(eventType, Function.identity(), result); + } } + /** + * The result of an expected event publication. + * + * @author Oliver Drotbohm + */ public class EventResult { + private static final String EXPECTED_EVENT = "Expected an event of type %s (potentially further constrained using matching clauses above) to be published but couldn't find one in %s!"; + private final Class type; private final Function, TypedPublishedEvents> filter; + private final ExecutionResult previousResult; /** * Creates a new {@link EventResult} for the given type and filter. * * @param type must not be {@literal null}. * @param filtered must not be {@literal null}. + * @param previousResult a potentially previously calculated result. */ - EventResult(Class type, Function, TypedPublishedEvents> filtered) { + EventResult(Class type, Function, TypedPublishedEvents> filtered, + ExecutionResult previousResult) { Assert.notNull(type, "Event type must not be null!"); this.type = type; this.filter = filtered; + this.previousResult = previousResult; } /** @@ -476,7 +508,7 @@ public class Scenario { Assert.notNull(filter, "Filter must not be null!"); - return new EventResult(type, createOrAdd(it -> it.matching(filter))); + return new EventResult(type, createOrAdd(it -> it.matching(filter)), previousResult); } /** @@ -489,7 +521,7 @@ public class Scenario { * @return will never be {@literal null}. */ public EventResult matchingMapped(Function extractor, Predicate filter) { - return new EventResult(type, createOrAdd(it -> it.matching(extractor, filter))); + return new EventResult(type, createOrAdd(it -> it.matching(extractor, filter)), previousResult); } /** @@ -501,7 +533,7 @@ public class Scenario { * @return will never be {@literal null}. */ public EventResult matchingMappedValue(Function extractor, @Nullable S value) { - return new EventResult(type, createOrAdd(it -> it.matching(extractor, value))); + return new EventResult(type, createOrAdd(it -> it.matching(extractor, value)), previousResult); } /** @@ -584,7 +616,18 @@ public class Scenario { } private void toArriveAndVerifyInternal(Consumer verifications) { - awaitInternal(verifications, () -> getFilteredEvents(), it -> it.eventOfTypeWasPublished(type)); + + if (previousResult != null) { + + assertThat(getFilteredEvents().eventOfTypeWasPublished(type)) + .overridingErrorMessage(EXPECTED_EVENT, type, events) + .isTrue(); + + verifications.accept(previousResult.second()); + + } else { + awaitInternal(verifications, () -> getFilteredEvents(), it -> it.eventOfTypeWasPublished(type)); + } } } } diff --git a/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java index 1a90cfc2..17aaa25e 100644 --- a/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java +++ b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java @@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor; import java.lang.Thread.UncaughtExceptionHandler; import java.time.Duration; +import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -34,6 +35,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.opentest4j.AssertionFailedError; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.PayloadApplicationEvent; import org.springframework.modulith.test.PublishedEventsAssert.PublishedEventAssert; @@ -396,6 +398,41 @@ class ScenarioUnitTests { verify(consumer).accept(any()); } + @Test // GH-185 + void attachedEventConstraintsAreVerified() { + + var runnable = mock(Runnable.class); + var events = new DefaultPublishedEvents(List.of(new SomeEvent("payload"))); + var publishedEvents = new DefaultAssertablePublishedEvents(events); + + assertThatNoException().isThrownBy(() -> { + new Scenario(tx, publisher, publishedEvents) + .stimulate(runnable) + .andWaitForStateChange(() -> true) + .andExpect(SomeEvent.class) + .matching(it -> it != null) + .toArrive(); + }); + + verify(runnable).run(); + } + + @Test // GH-185 + void failsAttachedEventConstraintsIfNoEventsPublished() { + + var runnable = mock(Runnable.class); + + assertThatExceptionOfType(AssertionFailedError.class) + .isThrownBy(() -> new Scenario(tx, publisher, new DefaultAssertablePublishedEvents()) + .stimulate(runnable) + .andWaitForStateChange(() -> true) + .andExpect(SomeEvent.class) + .matching(it -> it != null) + .toArrive()); + + verify(runnable).run(); + } + private Fixture givenAScenario(Consumer consumer) { return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents()); }