GH-185 - Additional simplification of delayed event verification on Scenarios.

This commit is contained in:
Oliver Drotbohm
2023-04-25 14:34:23 +02:00
parent 3a923519f2
commit bb800ba046
4 changed files with 110 additions and 8 deletions

View File

@@ -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();
}
}

View File

@@ -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<App
.map(type::cast));
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return events.isEmpty()
? "[]"
: events.stream().map(Object::toString).collect(Collectors.joining("[ ", ", ", " ]"));
}
private static Object unwrapPayloadEvent(Object source) {
return PayloadApplicationEvent.class.isInstance(source) //

View File

@@ -15,7 +15,10 @@
*/
package org.springframework.modulith.test;
import static org.assertj.core.api.Assertions.*;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
@@ -40,8 +43,6 @@ import org.springframework.transaction.support.TransactionOperations;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.Assert;
import com.tngtech.archunit.thirdparty.com.google.common.base.Optional;
/**
* A DSL to define integration testing scenarios for application modules. A {@link Scenario} starts with a stimulus on
* the system, usually a component invocation (see {@link #stimulate(Function)} or event publication (see
@@ -338,13 +339,16 @@ public class Scenario {
}
/**
* Expects an event of the given type to arrive. 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 <E> the type of the event.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @see #forEventOfType(Class)
*/
public <E> EventResult<E> andWaitForEventOfType(Class<E> type) {
return new EventResult<E>(type, Function.identity());
return new EventResult<E>(type, Function.identity(), null);
}
/**
@@ -402,6 +406,11 @@ public class Scenario {
private record ExecutionResult<S, T>(S first, T second) {}
/**
* The result of an expected state change.
*
* @author Oliver Drotbohm
*/
public class StateChangeResult<S> {
private ExecutionResult<S, T> 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 <E> the type of the event
* @param eventType must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <E> EventResult<E> andExpect(Class<E> eventType) {
return new EventResult<>(eventType, Function.identity(), result);
}
}
/**
* The result of an expected event publication.
*
* @author Oliver Drotbohm
*/
public class EventResult<E> {
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<E> type;
private final Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filter;
private final ExecutionResult<?, T> 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<E> type, Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filtered) {
EventResult(Class<E> type, Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filtered,
ExecutionResult<?, T> 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<E>(type, createOrAdd(it -> it.matching(filter)));
return new EventResult<E>(type, createOrAdd(it -> it.matching(filter)), previousResult);
}
/**
@@ -489,7 +521,7 @@ public class Scenario {
* @return will never be {@literal null}.
*/
public <S> EventResult<E> matchingMapped(Function<E, S> extractor, Predicate<? super S> filter) {
return new EventResult<E>(type, createOrAdd(it -> it.matching(extractor, filter)));
return new EventResult<E>(type, createOrAdd(it -> it.matching(extractor, filter)), previousResult);
}
/**
@@ -501,7 +533,7 @@ public class Scenario {
* @return will never be {@literal null}.
*/
public <S> EventResult<E> matchingMappedValue(Function<E, S> extractor, @Nullable S value) {
return new EventResult<E>(type, createOrAdd(it -> it.matching(extractor, value)));
return new EventResult<E>(type, createOrAdd(it -> it.matching(extractor, value)), previousResult);
}
/**
@@ -584,7 +616,18 @@ public class Scenario {
}
private void toArriveAndVerifyInternal(Consumer<T> 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));
}
}
}
}

View File

@@ -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<Scenario> consumer) {
return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents());
}