From 52fdc221a1c7f591ad31e392e73d30f5108468ca Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Mon, 20 Feb 2023 15:34:21 +0100 Subject: [PATCH] GH-136 - Introduce Scenario as application module integration test API. See the Javadoc of Scenario for details. --- spring-modulith-test/pom.xml | 6 +- .../modulith/test/ApplicationModuleTest.java | 3 +- .../modulith/test/DefaultPublishedEvents.java | 8 +- .../modulith/test/PublishedEvents.java | 37 +- .../modulith/test/PublishedEventsAssert.java | 4 +- .../modulith/test/Scenario.java | 580 ++++++++++++++++++ .../test/ScenarioParameterResolver.java | 78 +++ .../modulith/test/TypedEvents.java | 47 ++ .../modulith/test/ScenarioUnitTests.java | 446 ++++++++++++++ 9 files changed, 1178 insertions(+), 31 deletions(-) create mode 100644 spring-modulith-test/src/main/java/org/springframework/modulith/test/Scenario.java create mode 100644 spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioParameterResolver.java create mode 100644 spring-modulith-test/src/main/java/org/springframework/modulith/test/TypedEvents.java create mode 100644 spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java diff --git a/spring-modulith-test/pom.xml b/spring-modulith-test/pom.xml index 1c6edc8c..33124a3b 100644 --- a/spring-modulith-test/pom.xml +++ b/spring-modulith-test/pom.xml @@ -39,6 +39,11 @@ spring-test + + org.springframework + spring-tx + + org.assertj assertj-core @@ -59,7 +64,6 @@ org.awaitility awaitility - test diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/ApplicationModuleTest.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ApplicationModuleTest.java index 985596e1..be448698 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/ApplicationModuleTest.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ApplicationModuleTest.java @@ -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 { 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 8c4a1854..e37acf21 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 @@ -100,13 +100,13 @@ class DefaultPublishedEvents implements PublishedEvents, ApplicationListener TypedPublishedEvents ofSubType(Class subType) { + public TypedPublishedEvents ofType(Class type) { - return SimpleTypedPublishedEvents.of(getFilteredEvents(subType::isInstance) // - .map(subType::cast)); + return SimpleTypedPublishedEvents.of(getFilteredEvents(type::isInstance) // + .map(type::cast)); } /* diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEvents.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEvents.java index f0df2895..5cd9f85d 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEvents.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEvents.java @@ -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 the event type - * @param type must not be {@literal null}. - * @return + /* + * (non-Javadoc) + * @see org.springframework.modulith.test.TypedEvents#ofType(java.lang.Class) */ TypedPublishedEvents ofType(Class 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 the event type */ - interface TypedPublishedEvents extends Iterable { + interface TypedPublishedEvents extends Iterable, 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}. */ - TypedPublishedEvents ofSubType(Class subType); + default TypedPublishedEvents ofSubType(Class subType) { + return ofType(subType); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.test.TypedEvents#ofType(java.lang.Class) + */ + @Override + TypedPublishedEvents ofType(Class type); /** * Returns all {@link TypedPublishedEvents} that match the given predicate. diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsAssert.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsAssert.java index 325c8d1b..594392e8 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsAssert.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsAssert.java @@ -31,14 +31,14 @@ import org.springframework.util.Assert; * @author Oliver Drotbohm * @see AssertablePublishedEvents */ -public class PublishedEventsAssert extends AbstractAssert { +public class PublishedEventsAssert extends AbstractAssert { /** * Creates a new {@link PublishedEventsAssert} * * @param actual must not be {@literal null}. */ - PublishedEventsAssert(AssertablePublishedEvents actual) { + PublishedEventsAssert(TypedEvents actual) { super(actual, PublishedEventsAssert.class); } diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/Scenario.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/Scenario.java new file mode 100644 index 00000000..5969ecd1 --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/Scenario.java @@ -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. + *

+ * {@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 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 publish(Supplier... 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 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 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 When stimulate(Supplier 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 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 When stimulate(Supplier supplier, Consumer 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 the type of the value returned by the stimulus. + * @param function must not be {@literal null}. + * @return will never be {@literal null}. + */ + public When stimulate(Function 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 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 When stimulate(Function function, Consumer 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 stimulate(BiConsumer 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 stimulate(BiConsumer 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 the type of the result. + * @param stimulus must not be {@literal null}. + * @return will never be {@literal null}. + */ + public When stimulate(BiFunction 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 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 When stimulate(BiFunction stimulus, + Consumer 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 { + + private final BiFunction stimulus; + private final Consumer cleanup; + private final Function customizer; + + /** + * @param stimulus must not be {@literal null}. + * @param cleanup must not be {@literal null}. + * @param customizer must not be {@literal null}. + */ + When(BiFunction stimulus, Consumer cleanup, + Function 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 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 customize(Function customizer) { + + Assert.notNull(customizer, "Customizer must not be null!"); + + return new When(stimulus, cleanup, customizer); + } + + // Expect event + + /** + * Alternative to {@link #andWaitForEventOfType(Class)} for better readability if execution customizations have been + * applied before. + * + * @param the type of the event. + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @see #andWaitForEventOfType(Class) + */ + public EventResult forEventOfType(Class type) { + return andWaitForEventOfType(type); + } + + /** + * Alternative to {@link #andWaitForStateChange(Supplier)} for better readability if execution customizations have + * been applied before. + * + * @param the type of the state change result + * @param supplier must not be {@literal null}. + * @return will never be {@literal null}. + * @see #andWaitForStateChange(Supplier) + */ + public StateChangeResult forStateChange(Supplier supplier) { + return andWaitForStateChange(supplier); + } + + /** + * Alternative to {@link #andWaitForStateChange(Supplier, Predicate)} for better readability if execution + * customizations have been applied before. + * + * @param 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 StateChangeResult forStateChange(Supplier supplier, Predicate acceptanceCriteria) { + return andWaitForStateChange(supplier, acceptanceCriteria); + } + + /** + * @param 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()); + } + + /** + * 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 the type of the result. + * @param supplier must not be {@literal null}. + * @return will never be {@literal null}. + * @see #forStateChange(Supplier) + */ + public StateChangeResult andWaitForStateChange(Supplier supplier) { + + Predicate 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 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 StateChangeResult andWaitForStateChange(Supplier supplier, + Predicate 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 ExecutionResult awaitInternal(Consumer verifications, Callable supplier, + Predicate 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 first, T second) {} + + public class StateChangeResult { + + private ExecutionResult result; + + StateChangeResult(ExecutionResult 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 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 consumer) { + + Assert.notNull(consumer, "BiConsumer must not be null!"); + + consumer.accept(result.first(), result.second()); + } + } + + public class EventResult { + + private final Class type; + private final Function, TypedPublishedEvents> 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 type, Function, TypedPublishedEvents> 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 matching(Predicate filter) { + + Assert.notNull(filter, "Filter must not be null!"); + + return new EventResult(type, createOrAdd(it -> it.matching(filter))); + } + + /** + * Matches events that satisfy the given {@link Predicate} after extracting a value using the given + * {@link Function}. + * + * @param 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 EventResult matchingMapped(Function extractor, Predicate filter) { + return new EventResult(type, createOrAdd(it -> it.matching(extractor, filter))); + } + + /** + * Matches events that extracting the given value using the given {@link Function}. + * + * @param 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 EventResult matchingMappedValue(Function extractor, @Nullable S value) { + return new EventResult(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 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 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> 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, T> consumer) { + + Assert.notNull(consumer, "Consumer must not be null!"); + + toArriveAndVerifyInternal(it -> { + consumer.accept(getAssertedEvent(), it); + }); + } + + private Function, TypedPublishedEvents> createOrAdd( + Function, TypedPublishedEvents> filter) { + return this.filter == null ? filter : this.filter.andThen(filter); + } + + private TypedPublishedEvents getFilteredEvents() { + return filter.apply(events.ofType(type)); + } + + private PublishedEventAssert getAssertedEvent() { + return new PublishedEventsAssert(getFilteredEvents()).contains(type); + } + + private void toArriveAndVerifyInternal(Consumer verifications) { + awaitInternal(verifications, () -> getFilteredEvents(), it -> it.eventOfTypeWasPublished(type)); + } + } + } +} diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioParameterResolver.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioParameterResolver.java new file mode 100644 index 00000000..a9bc40a2 --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioParameterResolver.java @@ -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); + } +} diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/TypedEvents.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/TypedEvents.java new file mode 100644 index 00000000..51ed6beb --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/TypedEvents.java @@ -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 the event type + * @param type must not be {@literal null}. + * @return + */ + TypedPublishedEvents ofType(Class 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(); + } +} 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 new file mode 100644 index 00000000..8d2c2553 --- /dev/null +++ b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java @@ -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 consumer = it -> publishObject(it) + .forEventOfType(String.class) + .toArrive(); + + givenAScenario(consumer) + .waitFor(TIMED_OUT) + .onEvent(() -> "foo") + .expectFailure(); + } + + @Test // GH-136 + void failsIfNoEventOfExpectedTypeArrives() { + + Consumer consumer = it -> publishObject(it) + .forEventOfType(String.class) + .toArrive(); + + givenAScenario(consumer) + .onEvent(() -> 1L) + .expectFailure(); + } + + @Test // GH-136 + void matchesEventsWithPredicate() throws Throwable { + + Consumer 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 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 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 verification = mock(Consumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + + Consumer 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 verification = mock(Consumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + Consumer cleanupCallback = mock(Consumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + Consumer cleanupCallback = mock(Consumer.class); + + Consumer 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> verification = mock(Consumer.class); + + Consumer 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, Integer> verification = mock(BiConsumer.class); + + Consumer 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 verification = mock(Consumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + + Consumer 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 verification = mock(Consumer.class); + + Consumer 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 verification = mock(BiConsumer.class); + + Consumer 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 consumer) { + return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents()); + } + + private static When publishObject(Scenario source) { + return source.publish(new Object()) + .andWaitAtMost(WAIT_TIME); + } + + private static Supplier delayed(S value) { + return delayed(value, DELAY); + } + + private static Supplier 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 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 event) { + + Runnable runnable = () -> events.onApplicationEvent(new PayloadApplicationEvent(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); + } + } +}