GH-136 - Introduce Scenario as application module integration test API.

See the Javadoc of Scenario for details.
This commit is contained in:
Oliver Drotbohm
2023-02-20 15:34:21 +01:00
parent 9428d3be87
commit 52fdc221a1
9 changed files with 1178 additions and 31 deletions

View File

@@ -39,6 +39,11 @@
<artifactId>spring-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
@@ -59,7 +64,6 @@
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

View File

@@ -47,8 +47,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension;
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@TypeExcludeFilters(ModuleTypeExcludeFilter.class)
@ImportAutoConfiguration(ModuleTestAutoConfiguration.class)
@ExtendWith(SpringExtension.class)
@ExtendWith(PublishedEventsParameterResolver.class)
@ExtendWith({ SpringExtension.class, PublishedEventsParameterResolver.class, ScenarioParameterResolver.class })
@TestInstance(Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = AutowireMode.ALL)
public @interface ApplicationModuleTest {

View File

@@ -100,13 +100,13 @@ class DefaultPublishedEvents implements PublishedEvents, ApplicationListener<App
/*
* (non-Javadoc)
* @see org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents#ofSubType(java.lang.Class)
* @see org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents#ofType(java.lang.Class)
*/
@Override
public <S extends T> TypedPublishedEvents<S> ofSubType(Class<S> subType) {
public <S> TypedPublishedEvents<S> ofType(Class<S> type) {
return SimpleTypedPublishedEvents.of(getFilteredEvents(subType::isInstance) //
.map(subType::cast));
return SimpleTypedPublishedEvents.of(getFilteredEvents(type::isInstance) //
.map(type::cast));
}
/*

View File

@@ -28,7 +28,7 @@ import org.springframework.util.Assert;
*
* @author Oliver Drotbohm
*/
public interface PublishedEvents {
public interface PublishedEvents extends TypedEvents {
/**
* Creates a new {@link PublishedEvents} instance for the given events.
@@ -53,35 +53,19 @@ public interface PublishedEvents {
return new DefaultPublishedEvents(events);
}
/**
* Returns all application events of the given type that were fired during the test execution.
*
* @param <T> the event type
* @param type must not be {@literal null}.
* @return
/*
* (non-Javadoc)
* @see org.springframework.modulith.test.TypedEvents#ofType(java.lang.Class)
*/
<T> TypedPublishedEvents<T> ofType(Class<T> type);
/**
* Returns whether an event of the given type was published.
*
* @param type must not be {@literal null}.
* @return whether an event of the given type was published.
*/
default boolean eventOfTypeWasPublished(Class<?> type) {
Assert.notNull(type, "Event type must not be null!");
return ofType(type).iterator().hasNext();
}
/**
* All application events of a given type that were fired during a test execution.
*
* @author Oliver Drotbohm
* @param <T> the event type
*/
interface TypedPublishedEvents<T> extends Iterable<T> {
interface TypedPublishedEvents<T> extends Iterable<T>, TypedEvents {
/**
* Further constrain the event type for downstream assertions.
@@ -90,7 +74,16 @@ public interface PublishedEvents {
* @param subType the sub type
* @return will never be {@literal null}.
*/
<S extends T> TypedPublishedEvents<S> ofSubType(Class<S> subType);
default <S extends T> TypedPublishedEvents<S> ofSubType(Class<S> subType) {
return ofType(subType);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.test.TypedEvents#ofType(java.lang.Class)
*/
@Override
<S> TypedPublishedEvents<S> ofType(Class<S> type);
/**
* Returns all {@link TypedPublishedEvents} that match the given predicate.

View File

@@ -31,14 +31,14 @@ import org.springframework.util.Assert;
* @author Oliver Drotbohm
* @see AssertablePublishedEvents
*/
public class PublishedEventsAssert extends AbstractAssert<PublishedEventsAssert, PublishedEvents> {
public class PublishedEventsAssert extends AbstractAssert<PublishedEventsAssert, TypedEvents> {
/**
* Creates a new {@link PublishedEventsAssert}
*
* @param actual must not be {@literal null}.
*/
PublishedEventsAssert(AssertablePublishedEvents actual) {
PublishedEventsAssert(TypedEvents actual) {
super(actual, PublishedEventsAssert.class);
}

View File

@@ -0,0 +1,580 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.test;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.lang.Nullable;
import org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents;
import org.springframework.modulith.test.PublishedEventsAssert.PublishedEventAssert;
import org.springframework.modulith.test.Scenario.When.EventResult;
import org.springframework.modulith.test.Scenario.When.StateChangeResult;
import org.springframework.transaction.support.TransactionOperations;
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
* {@link #publish(Object...)}) and a definition of the expected outcome. That can be a state change observed by
* invoking application module components (see {@link When#andWaitForStateChange(Supplier)}) or another event being
* published (see {@link When#andWaitForEventOfType(Class)}), concluded by additional verifications.
* <p>
* {@link Scenario} can be used as JUnit test method parameter in {@link ApplicationModuleTest}s.
*
* @author Oliver Drotbohm
* @see ApplicationModuleTest
*/
@API(status = Status.EXPERIMENTAL)
public class Scenario {
private final TransactionOperations transactionOperations;
private final ApplicationEventPublisher publisher;
private final AssertablePublishedEvents events;
/**
* Creates a new {@link Scenario} for the given {@link TransactionOperations}, {@link ApplicationEventPublisher} and
* {@link AssertablePublishedEvents}.
*
* @param transactionOperations must not be {@literal null}.
* @param publisher must not be {@literal null}.
* @param events must not be {@literal null}.
*/
Scenario(TransactionOperations transactionOperations, ApplicationEventPublisher publisher,
AssertablePublishedEvents events) {
Assert.notNull(transactionOperations, "TransactionOperations must not be null!");
Assert.notNull(publisher, "ApplicationEventPublisher must not be null!");
Assert.notNull(events, "AssertablePublishedEvents must not be null!");
this.transactionOperations = transactionOperations;
this.publisher = publisher;
this.events = events;
}
/**
* Publishes the given event. The event will be published in a transaction to make sure that transactional event
* listeners are invoked as well.
*
* @param event must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<Void> publish(Object... event) {
return stimulate((tx, e) -> {
tx.executeWithoutResult(__ -> Arrays.stream(event).forEach(e::publishEvent));
});
}
/**
* Publishes the event provided by the given {@link Supplier}. The event will be published in a transaction to make
* sure that transactional event listeners are invoked as well.
*
* @param events must not be {@literal null}.
* @return will never be {@literal null}.
*/
@SuppressWarnings("unchecked")
public When<Void> publish(Supplier<Object>... events) {
return stimulate((tx, e) -> {
tx.executeWithoutResult(__ -> Arrays.stream(events).map(Supplier::get).forEach(e::publishEvent));
});
}
/**
* Stimulates the system by executing the given {@link Runnable}.
*
* @param runnable must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<Void> stimulate(Runnable runnable) {
Assert.notNull(runnable, "Runnable must not be null!");
return stimulate(() -> {
runnable.run();
return null;
}, __ -> {});
}
/**
* Stimulates the system using the given {@link Supplier} and keeping the supplied value around for later
* verification.
*
* @param <S> the type of the value returned by the stimulus.
* @param supplier must not be {@literal null}.
* @return will never be {@literal null}.
* @see StateChangeResult#andVerify(Consumer)
* @see EventResult#toArriveAndVerify(Consumer)
*/
public <S> When<S> stimulate(Supplier<S> supplier) {
return stimulate(supplier, __ -> {});
}
/**
* Stimulates the system using the given {@link Supplier}, keeping the supplied value around for later verification
* but also registers a cleanup callback to make sure potential state changes are rolled back for the test execution.
*
* @param <S> the type of the value returned by the stimulus.
* @param supplier must not be {@literal null}.
* @param cleanupCallback must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <S> When<S> stimulate(Supplier<S> supplier, Consumer<S> cleanupCallback) {
Assert.notNull(supplier, "Supplier must not be null!");
Assert.notNull(cleanupCallback, "Cleanup callback must not be null!");
return stimulate((tx, e) -> supplier.get(), cleanupCallback);
}
/**
* Stimulates the system using the given function providing access to the {@link TransactionOperations} and keeping
* the supplied value around for later verification.
*
* @param <S> the type of the value returned by the stimulus.
* @param function must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <S> When<S> stimulate(Function<TransactionOperations, S> function) {
return stimulate(function, __ -> {});
}
/**
* Stimulates the system using the given function providing access to the {@link TransactionOperations} and keeping
* the supplied value around for later verification but also registers a cleanup callback to make sure potential state
* changes are rolled back for the test execution.
*
* @param <S> the type of the value returned by the stimulus.
* @param function must not be {@literal null}.
* @param cleanupCallback must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <S> When<S> stimulate(Function<TransactionOperations, S> function, Consumer<S> cleanupCallback) {
Assert.notNull(function, "Function must not be null!");
Assert.notNull(cleanupCallback, "Cleanup callback must not be null!");
return stimulate((tx, events) -> function.apply(tx), cleanupCallback);
}
/**
* Stimulate the system using the given {@link TransactionOperations} and {@link ApplicationEventPublisher}. Usually a
* method on some application service or event publication is triggered.
*
* @param stimulus must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<Void> stimulate(BiConsumer<TransactionOperations, ApplicationEventPublisher> stimulus) {
Assert.notNull(stimulus, "Stimulus must not be null!");
return stimulate(stimulus, () -> {});
}
/**
* @param stimulus must not be {@literal null}.
* @param cleanupCallback must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<Void> stimulate(BiConsumer<TransactionOperations, ApplicationEventPublisher> stimulus,
Runnable cleanupCallback) {
Assert.notNull(stimulus, "Stimulus must not be null!");
Assert.notNull(cleanupCallback, "Cleanup callback must not be null!");
return stimulate((tx, e) -> {
stimulus.accept(tx, e);
return (Void) null;
}, __ -> cleanupCallback.run());
}
/**
* Stimulate the system using the given {@link TransactionOperations} and {@link ApplicationEventPublisher} and
* produce a result. Usually a method on some application service or event publication is triggered.
*
* @param <S> the type of the result.
* @param stimulus must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <S> When<S> stimulate(BiFunction<TransactionOperations, ApplicationEventPublisher, S> stimulus) {
Assert.notNull(stimulus, "Stimulus must not be null!");
return stimulate(stimulus, __ -> {});
}
/**
* Stimulate the system using the given {@link TransactionOperations} and {@link ApplicationEventPublisher} (usually a
* method on some application service or event publication is triggered), produce a result and a callback to clean up
* after the verification has completed.
*
* @param <S> the type of the result.
* @param stimulus must not be {@literal null}.
* @param cleanupCallback must not be {@literal null}.
* @return will never be {@literal null}.
*/
public <S> When<S> stimulate(BiFunction<TransactionOperations, ApplicationEventPublisher, S> stimulus,
Consumer<S> cleanupCallback) {
Assert.notNull(stimulus, "Stimulus must not be null!");
Assert.notNull(cleanupCallback, "Cleanup callback must not be null!");
return new When<>(stimulus, cleanupCallback, Function.identity());
}
public class When<T> {
private final BiFunction<TransactionOperations, ApplicationEventPublisher, T> stimulus;
private final Consumer<T> cleanup;
private final Function<ConditionFactory, ConditionFactory> customizer;
/**
* @param stimulus must not be {@literal null}.
* @param cleanup must not be {@literal null}.
* @param customizer must not be {@literal null}.
*/
When(BiFunction<TransactionOperations, ApplicationEventPublisher, T> stimulus, Consumer<T> cleanup,
Function<ConditionFactory, ConditionFactory> customizer) {
this.stimulus = stimulus;
this.cleanup = cleanup;
this.customizer = customizer;
}
// Customize
/**
* Configures the {@link Scenario} to wait for at most the given duration for an event of the subsequent
* specification to arrive.
*
* @param duration must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<T> andWaitAtMost(Duration duration) {
Assert.notNull(duration, "Duration must not be null!");
return customize(it -> it.atMost(duration));
}
/**
* @param customizer must not be {@literal null}.
* @return will never be {@literal null}.
*/
public When<T> customize(Function<ConditionFactory, ConditionFactory> customizer) {
Assert.notNull(customizer, "Customizer must not be null!");
return new When<T>(stimulus, cleanup, customizer);
}
// Expect event
/**
* Alternative to {@link #andWaitForEventOfType(Class)} for better readability if execution customizations have been
* applied before.
*
* @param <E> the type of the event.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @see #andWaitForEventOfType(Class)
*/
public <E> EventResult<E> forEventOfType(Class<E> type) {
return andWaitForEventOfType(type);
}
/**
* Alternative to {@link #andWaitForStateChange(Supplier)} for better readability if execution customizations have
* been applied before.
*
* @param <S> the type of the state change result
* @param supplier must not be {@literal null}.
* @return will never be {@literal null}.
* @see #andWaitForStateChange(Supplier)
*/
public <S> StateChangeResult<S> forStateChange(Supplier<S> supplier) {
return andWaitForStateChange(supplier);
}
/**
* Alternative to {@link #andWaitForStateChange(Supplier, Predicate)} for better readability if execution
* customizations have been applied before.
*
* @param <S> the type of the state change result
* @param supplier must not be {@literal null}.
* @param acceptanceCriteria must not be {@literal null}.
* @return will never be {@literal null}.
* @see #andWaitForStateChange(Supplier, Predicate)
*/
public <S> StateChangeResult<S> forStateChange(Supplier<S> supplier, Predicate<? super S> acceptanceCriteria) {
return andWaitForStateChange(supplier, acceptanceCriteria);
}
/**
* @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());
}
/**
* Expects a particular state change on the module to produce a result and uses the given {@link Predicate} to
* determine whether the value is conclusive.
*
* @param <S> the type of the result.
* @param supplier must not be {@literal null}.
* @return will never be {@literal null}.
* @see #forStateChange(Supplier)
*/
public <S> StateChangeResult<S> andWaitForStateChange(Supplier<S> supplier) {
Predicate<S> acceptanceCriteria = it -> it instanceof Optional<?> o ? o.isPresent() : it != null;
return andWaitForStateChange(supplier, acceptanceCriteria);
}
/**
* Expects a particular state change on the module to produce a result. By default, a non-{@literal null} value
* would indicate success, except for {@link java.util.Optional}s, in which case we'd check for the presence of a
* value. For more control about the result matching, use {@link #andWaitForStateChange(Supplier, Predicate)}
*
* @param <S> the type of the result for the state change
* @param supplier must not be {@literal null}.
* @param acceptanceCriteria must not be {@literal null}.
* @return will never be {@literal null}.
* @see #andWaitForStateChange(Supplier, Predicate)
*/
public <S> StateChangeResult<S> andWaitForStateChange(Supplier<S> supplier,
Predicate<? super S> acceptanceCriteria) {
Assert.notNull(supplier, "Supplier must not be null!");
Assert.notNull(acceptanceCriteria, "Acceptance criteria must not be null!");
return new StateChangeResult<>(awaitInternal(__ -> {}, () -> supplier.get(), acceptanceCriteria));
}
private <S> ExecutionResult<S, T> awaitInternal(Consumer<T> verifications, Callable<S> supplier,
Predicate<? super S> condition) {
T result = stimulus.apply(transactionOperations, publisher);
try {
S foo = customizer.apply(Awaitility.await())
.until(supplier, condition);
verifications.accept(result);
return new ExecutionResult<>(foo, result);
} finally {
cleanup.accept(result);
}
}
private record ExecutionResult<S, T>(S first, T second) {}
public class StateChangeResult<S> {
private ExecutionResult<S, T> result;
StateChangeResult(ExecutionResult<S, T> result) {
this.result = result;
}
/**
* Verifies the state change result using the given {@link Consumer}.
*
* @param consumer must not be {@literal null}.
*/
public void andVerify(Consumer<S> consumer) {
Assert.notNull(consumer, "Consumer must not be null!");
consumer.accept(result.first());
}
/**
* Verifies the state change result and stimulus result using the given {@link BiConsumer}.
*
* @param consumer must not be {@literal null}.
*/
public void andVerify(BiConsumer<S, T> consumer) {
Assert.notNull(consumer, "BiConsumer must not be null!");
consumer.accept(result.first(), result.second());
}
}
public class EventResult<E> {
private final Class<E> type;
private final Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filter;
/**
* 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}.
*/
EventResult(Class<E> type, Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filtered) {
Assert.notNull(type, "Event type must not be null!");
this.type = type;
this.filter = filtered;
}
/**
* Matches events that satisfy the given {@link Predicate}.
*
* @param filter must not be {@literal null}.
* @return will never be {@literal null}.
*/
public EventResult<E> matching(Predicate<? super E> filter) {
Assert.notNull(filter, "Filter must not be null!");
return new EventResult<E>(type, createOrAdd(it -> it.matching(filter)));
}
/**
* Matches events that satisfy the given {@link Predicate} after extracting a value using the given
* {@link Function}.
*
* @param <S> the type of the extracted value.
* @param extractor must not be {@literal null}.
* @param filter must not be {@literal null}.
* @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)));
}
/**
* Matches events that extracting the given value using the given {@link Function}.
*
* @param <S> the type of the extracted value.
* @param extractor must not be {@literal null}.
* @param value can be {@literal null}.
* @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)));
}
/**
* Awaits an event of the given specification to arrive.
*/
public void toArrive() {
toArriveAndVerifyInternal(__ -> {});
}
/**
* Awaits an event of the given specification to arrive and invokes the given consumer with it.
*
* @param consumer must not be {@literal null}.
*/
public void toArriveAndVerify(Consumer<E> consumer) {
Assert.notNull(consumer, "Consumer must not be null!");
toArriveAndVerifyInternal(__ -> {
consumer.accept(getFilteredEvents().iterator().next());
});
}
/**
* Awaits an event of the given specification to arrive and invokes the given consumer with it as well as the
* result created by the stimulus.
*
* @param consumer must not be {@literal null}.
*/
public void toArriveAndVerify(BiConsumer<E, T> consumer) {
Assert.notNull(consumer, "Consumer must not be null!");
toArriveAndVerifyInternal(it -> {
consumer.accept(getFilteredEvents().iterator().next(), it);
});
}
/**
* Expects the events previously specified to arrive and additionally assert the {@link PublishedEventsAssert} for
* the captured event.
*
* @param consumer must not be {@literal null}.
*/
public void toArriveAndAssert(Consumer<PublishedEventAssert<? super E>> consumer) {
Assert.notNull(consumer, "Consumer must not be null!");
toArriveAndVerifyInternal(__ -> {
consumer.accept(getAssertedEvent());
});
}
/**
* Expects the events previously specified to arrive and additionally assert the {@link PublishedEventsAssert} for
* the captured event and original stimulus result.
*
* @param consumer must not be {@literal null}.
*/
public void toArriveAndAssert(BiConsumer<PublishedEventAssert<? super E>, T> consumer) {
Assert.notNull(consumer, "Consumer must not be null!");
toArriveAndVerifyInternal(it -> {
consumer.accept(getAssertedEvent(), it);
});
}
private Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> createOrAdd(
Function<TypedPublishedEvents<E>, TypedPublishedEvents<E>> filter) {
return this.filter == null ? filter : this.filter.andThen(filter);
}
private TypedPublishedEvents<E> getFilteredEvents() {
return filter.apply(events.ofType(type));
}
private PublishedEventAssert<? super E> getAssertedEvent() {
return new PublishedEventsAssert(getFilteredEvents()).contains(type);
}
private void toArriveAndVerifyInternal(Consumer<T> verifications) {
awaitInternal(verifications, () -> getFilteredEvents(), it -> it.eventOfTypeWasPublished(type));
}
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.test;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.support.TransactionOperations;
/**
* JUnit {@link ParameterResolver} for {@link Scenario}.
*
* @author Oliver Drotbohm
*/
class ScenarioParameterResolver implements ParameterResolver, BeforeAllCallback {
private final PublishedEventsParameterResolver delegate;
/**
* Creates a new {@link ScenarioParameterResolver}.
*/
public ScenarioParameterResolver() {
this.delegate = new PublishedEventsParameterResolver();
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.ParameterResolver#supportsParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
var type = parameterContext.getParameter().getType();
return Scenario.class.isAssignableFrom(type);
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.BeforeAllCallback#beforeAll(org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public void beforeAll(ExtensionContext context) throws Exception {
delegate.beforeAll(context);
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.ParameterResolver#resolveParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
var context = SpringExtension.getApplicationContext(extensionContext);
var operations = context.getBean(TransactionOperations.class);
var events = (AssertablePublishedEvents) delegate.resolveParameter(parameterContext, extensionContext);
return new Scenario(operations, context, events);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.test;
import org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents;
import org.springframework.util.Assert;
/**
* @author Oliver Drotbohm
*/
interface TypedEvents {
/**
* Returns all events of the given type.
*
* @param <T> the event type
* @param type must not be {@literal null}.
* @return
*/
<T> TypedPublishedEvents<T> ofType(Class<T> type);
/**
* Returns whether an event of the given type was published.
*
* @param type must not be {@literal null}.
* @return whether an event of the given type was published.
*/
default boolean eventOfTypeWasPublished(Class<?> type) {
Assert.notNull(type, "Event type must not be null!");
return ofType(type).iterator().hasNext();
}
}

View File

@@ -0,0 +1,446 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import lombok.RequiredArgsConstructor;
import java.lang.Thread.UncaughtExceptionHandler;
import java.time.Duration;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Supplier;
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.springframework.context.ApplicationEventPublisher;
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.modulith.test.PublishedEventsAssert.PublishedEventAssert;
import org.springframework.modulith.test.Scenario.When;
import org.springframework.transaction.support.TransactionOperations;
/**
* Unit tests for {@link Scenario}.
*
* @author Oliver Drotbohm
*/
@ExtendWith(MockitoExtension.class)
public class ScenarioUnitTests {
private static final Duration DELAY = Duration.ofMillis(50);
private static final Duration WAIT_TIME = Duration.ofMillis(101);
private static final Duration TIMED_OUT = Duration.ofMillis(150);
@Mock TransactionOperations tx;
@Mock ApplicationEventPublisher publisher;
@Test // GH-136
void timesOutIfNoEventArrivesInTime() throws Throwable {
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArrive();
givenAScenario(consumer)
.waitFor(TIMED_OUT)
.onEvent(() -> "foo")
.expectFailure();
}
@Test // GH-136
void failsIfNoEventOfExpectedTypeArrives() {
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArrive();
givenAScenario(consumer)
.onEvent(() -> 1L)
.expectFailure();
}
@Test // GH-136
void matchesEventsWithPredicate() throws Throwable {
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(SomeEvent.class)
.matching(__ -> __.payload().startsWith("foo"))
.toArrive();
givenAScenario(consumer)
.onEvent(() -> new SomeEvent("foo"))
.expectSuccess();
}
@Test // GH-136
void matchesEventsExtractingPayloadWithPredicate() throws Throwable {
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(SomeEvent.class)
.matchingMapped(SomeEvent::payload, __ -> __.startsWith("foo"))
.toArrive();
givenAScenario(consumer)
.onEvent(() -> new SomeEvent("foo"))
.expectSuccess();
}
@Test // GH-136
void matchesEventsExtractingPayloadWithValue() throws Throwable {
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(SomeEvent.class)
.matchingMappedValue(SomeEvent::payload, "foo")
.toArrive();
givenAScenario(consumer)
.onEvent(() -> new SomeEvent("foo"))
.expectSuccess();
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersVerificationOnSuccess() {
Consumer<String> verification = mock(Consumer.class);
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> "foo")
.expectSuccess();
verify(verification).accept("foo");
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersVerificationWithResultOnSuccess() {
BiConsumer<String, Integer> verification = mock(BiConsumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> "foo")
.expectSuccess();
verify(verification).accept("foo", 41);
}
@Test // GH-136
@SuppressWarnings("unchecked")
void doesNotTriggerVerificationOnFailure() {
Consumer<String> verification = mock(Consumer.class);
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> 1)
.expectFailure();
verify(verification, never()).accept(any());
}
@Test // GH-136
@SuppressWarnings("unchecked")
void doesNotTriggerVerificationWithResultOnFailure() {
BiConsumer<String, Void> verification = mock(BiConsumer.class);
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> 1)
.expectFailure();
verify(verification, never()).accept(any(), any());
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersCleanupOnSuccess() {
BiConsumer<String, Integer> verification = mock(BiConsumer.class);
Consumer<Integer> cleanupCallback = mock(Consumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41, cleanupCallback)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> "foo")
.expectSuccess();
verify(verification).accept("foo", 41);
verify(cleanupCallback).accept(41);
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersCleanupOnFailure() {
BiConsumer<String, Integer> verification = mock(BiConsumer.class);
Consumer<Integer> cleanupCallback = mock(Consumer.class);
Consumer<Scenario> consumer = it -> it.stimulate((tx) -> 41, cleanupCallback)
.andWaitAtMost(WAIT_TIME)
.forEventOfType(String.class)
.toArriveAndVerify(verification);
givenAScenario(consumer)
.onEvent(() -> 4711)
.expectFailure();
verify(verification, never()).accept(any(), any());
verify(cleanupCallback).accept(41);
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersAssertOnSuccess() {
Consumer<PublishedEventAssert<? super String>> verification = mock(Consumer.class);
Consumer<Scenario> consumer = it -> publishObject(it)
.forEventOfType(String.class)
.toArriveAndAssert(verification);
givenAScenario(consumer)
.onEvent(() -> "foo")
.expectSuccess();
verify(verification).accept(any());
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersAssertWithResultOnSuccess() {
BiConsumer<PublishedEventAssert<? super String>, Integer> verification = mock(BiConsumer.class);
Consumer<Scenario> consumer = it -> it.stimulate((tx) -> 41)
.forEventOfType(String.class)
.toArriveAndAssert(verification);
givenAScenario(consumer)
.onEvent(() -> "foo")
.expectSuccess();
verify(verification).accept(any(), eq(41));
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersVerificationOnSuccessfulStateChange() {
Consumer<String> verification = mock(Consumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41)
.andWaitForStateChange(delayed("Foo"))
.andVerify(verification);
givenAScenario(consumer)
.expectSuccess();
verify(verification).accept("Foo");
}
@Test // GH-136
@SuppressWarnings("unchecked")
void triggersVerificationOnSuccessfulStateChangeWithResult() {
BiConsumer<String, Integer> verification = mock(BiConsumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41)
.andWaitForStateChange(delayed("Foo"))
.andVerify(verification);
givenAScenario(consumer)
.expectSuccess();
verify(verification).accept("Foo", 41);
}
@Test // GH-136
@SuppressWarnings("unchecked")
void doesNotTriggerVerificationOnFailedStateChange() {
Consumer<String> verification = mock(Consumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41)
.andWaitAtMost(WAIT_TIME)
.forStateChange(delayed("Foo", TIMED_OUT))
.andVerify(verification);
givenAScenario(consumer)
.expectFailure();
verify(verification, never()).accept(any());
}
@Test // GH-136
@SuppressWarnings("unchecked")
void doesNotTriggerVerificationWithResultOnFailedStateChange() {
BiConsumer<String, Integer> verification = mock(BiConsumer.class);
Consumer<Scenario> consumer = it -> it.stimulate(() -> 41)
.andWaitAtMost(WAIT_TIME)
.forStateChange(delayed("Foo", TIMED_OUT))
.andVerify(verification);
givenAScenario(consumer)
.expectFailure();
verify(verification, never()).accept(any(), any());
}
private Fixture givenAScenario(Consumer<Scenario> consumer) {
return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents());
}
private static When<Void> publishObject(Scenario source) {
return source.publish(new Object())
.andWaitAtMost(WAIT_TIME);
}
private static <S> Supplier<S> delayed(S value) {
return delayed(value, DELAY);
}
private static <S> Supplier<S> delayed(S value, Duration duration) {
return () -> {
try {
Thread.sleep(duration.toMillis());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return value;
};
}
@RequiredArgsConstructor
class Fixture {
private final Consumer<Scenario> consumer;
private final Duration duration;
private final Runnable runnable;
private final DefaultAssertablePublishedEvents events;
private CapturingExceptionHandler exceptionHandler = new CapturingExceptionHandler();
public Fixture waitFor(Duration duration) {
return new Fixture(consumer, duration, runnable, events);
}
public Fixture onEvent(Supplier<Object> event) {
Runnable runnable = () -> events.onApplicationEvent(new PayloadApplicationEvent<Object>(this, event.get()));
return new Fixture(consumer, duration, runnable, events);
}
public void expectSuccess() {
foo();
exceptionHandler.throwIfCaught();
}
public void expectFailure() {
foo();
assertThat(exceptionHandler.caught)
.as("Expected failure but did not see an exception!")
.isNotNull();
}
private void foo() {
try {
Runnable foo = () -> consumer.accept(new Scenario(tx, publisher, events));
var thread = new Thread(foo);
thread.setUncaughtExceptionHandler(exceptionHandler);
thread.start();
Thread.sleep(duration.toMillis());
if (runnable != null) {
runnable.run();
}
thread.join();
} catch (Throwable o_O) {
if (o_O instanceof RuntimeException e) {
throw e;
}
throw new RuntimeException(o_O);
}
}
}
record SomeEvent(String payload) {}
static class CapturingExceptionHandler implements UncaughtExceptionHandler {
Throwable caught;
/*
* (non-Javadoc)
* @see java.lang.Thread.UncaughtExceptionHandler#uncaughtException(java.lang.Thread, java.lang.Throwable)
*/
@Override
public void uncaughtException(Thread t, Throwable e) {
caught = e;
}
public void throwIfCaught() {
if (caught == null) {
return;
}
if (caught instanceof RuntimeException e) {
throw e;
}
throw new RuntimeException(caught);
}
}
}