diff --git a/spring-modulith-example/src/test/java/example/order/OrderIntegrationTests.java b/spring-modulith-example/src/test/java/example/order/OrderIntegrationTests.java index 2c7f9b73..04e8deb6 100644 --- a/spring-modulith-example/src/test/java/example/order/OrderIntegrationTests.java +++ b/spring-modulith-example/src/test/java/example/order/OrderIntegrationTests.java @@ -21,7 +21,7 @@ import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.Test; import org.springframework.modulith.test.ApplicationModuleTest; -import org.springframework.modulith.test.PublishedEvents; +import org.springframework.modulith.test.AssertablePublishedEvents; /** * @author Oliver Drotbohm @@ -33,15 +33,23 @@ class OrderIntegrationTests { private final OrderManagement orders; @Test - void publishesOrderCompletion(PublishedEvents events) { + void publishesOrderCompletion(AssertablePublishedEvents events) { var reference = new Order(); orders.complete(reference); + // Verification + var matchingMapped = events.ofType(OrderCompleted.class) - .matchingMapped(OrderCompleted::getOrderId, reference.getId()::equals); + .matching(OrderCompleted::getOrderId, reference.getId()); assertThat(matchingMapped).hasSize(1); + + // AssertJ + + assertThat(events) + .contains(OrderCompleted.class) + .matching(OrderCompleted::getOrderId, reference.getId()); } } diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/AssertablePublishedEvents.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/AssertablePublishedEvents.java new file mode 100644 index 00000000..b5c80c8a --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/AssertablePublishedEvents.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022-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.assertj.core.api.AssertProvider; + +/** + * An AssertJ-based extension of {@link PublishedEvents} to obtain fluent assertions. + * + * @author Oliver Drotbohm + */ +public interface AssertablePublishedEvents extends PublishedEvents, AssertProvider { + + /* + * (non-Javadoc) + * @see org.assertj.core.api.AssertProvider#assertThat() + */ + @Override + public default PublishedEventsAssert assertThat() { + return new PublishedEventsAssert(this); + } +} 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 new file mode 100644 index 00000000..a7692fa3 --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/DefaultAssertablePublishedEvents.java @@ -0,0 +1,57 @@ +/* + * 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 lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; + +/** + * Default implementation of {@link AssertablePublishedEvents}. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +class DefaultAssertablePublishedEvents implements AssertablePublishedEvents, ApplicationListener { + + private final DefaultPublishedEvents delegate; + + /** + * Creates a new {@link DefaultAssertablePublishedEvents}. + */ + DefaultAssertablePublishedEvents() { + this(new DefaultPublishedEvents()); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.test.PublishedEvents#ofType(java.lang.Class) + */ + @Override + public TypedPublishedEvents ofType(Class type) { + return delegate.ofType(type); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(ApplicationEvent event) { + delegate.onApplicationEvent(event); + } +} 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 73751f34..8f182414 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 @@ -22,9 +22,9 @@ import java.util.Collection; import java.util.Collections; import java.util.Iterator; 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; @@ -106,7 +106,6 @@ class DefaultPublishedEvents implements PublishedEvents, ApplicationListener TypedPublishedEvents matchingMapped(Function mapper, Predicate predicate) { + public TypedPublishedEvents matching(Function mapper, Predicate predicate) { + return SimpleTypedPublishedEvents.of(events.stream().filter(it -> predicate.test(mapper.apply(it)))); + } - return SimpleTypedPublishedEvents.of(events.stream().flatMap(it -> { - - S mapped = mapper.apply(it); - - return predicate.test(mapped) ? Stream.of(it) : Stream.empty(); - - })); + /* + * (non-Javadoc) + * @see org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents#matching(java.util.function.Function, java.lang.Object) + */ + @Override + public TypedPublishedEvents matching(Function mapper, S value) { + return matching(mapper, (Predicate) it -> Objects.equals(it, value)); } /** 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 1170c643..f0df2895 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 @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.function.Function; import java.util.function.Predicate; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -61,6 +62,19 @@ public interface PublishedEvents { */ 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. * @@ -91,9 +105,35 @@ public interface PublishedEvents { * * @param the intermediate type to apply the {@link Predicate} on * @param mapper the mapping step to extract a part of the original event subject to test for the {@link Predicate}. - * @param predicate the {@link Predicate} to apply on the value extracted. + * Must not be {@literal null}. + * @param predicate the {@link Predicate} to apply on the value extracted. Must not be {@literal null}. * @return will never be {@literal null}. */ - TypedPublishedEvents matchingMapped(Function mapper, Predicate predicate); + TypedPublishedEvents matching(Function mapper, Predicate predicate); + + /** + * Returns all {@link TypedPublishedEvents} that match the given value after applying the given mapping step. + * + * @param the intermediate type to apply the {@link Predicate} on + * @param mapper the mapping step to extract a part of the original event subject to verify against the given value, + * must not be {@literal null}. + * @param value the value expected as outcome of the mapping step, can be {@literal null}. + * @return will never be {@literal null}. + */ + TypedPublishedEvents matching(Function mapper, @Nullable S value); + + /** + * Returns all {@link TypedPublishedEvents} that match the given predicate after applying the given mapping step. + * + * @param the intermediate type to apply the {@link Predicate} on + * @param mapper the mapping step to extract a part of the original event subject to test for the {@link Predicate}. + * @param predicate the {@link Predicate} to apply on the value extracted. + * @return will never be {@literal null}. + * @deprecated since 0.3, use {@link #matching(Function, Predicate)} instead. + */ + @Deprecated(forRemoval = true, since = "0.3") + default TypedPublishedEvents matchingMapped(Function mapper, Predicate predicate) { + return matching(mapper, 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 new file mode 100644 index 00000000..7682e3ca --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsAssert.java @@ -0,0 +1,138 @@ +/* + * Copyright 2022-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 lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.function.Function; +import java.util.function.Predicate; + +import org.assertj.core.api.AbstractAssert; +import org.springframework.lang.Nullable; +import org.springframework.modulith.test.PublishedEvents.TypedPublishedEvents; +import org.springframework.util.Assert; + +/** + * AssertJ {@link org.assertj.core.api.Assert} for {@link PublishedEvents}. + * + * @author Oliver Drotbohm + * @see AssertablePublishedEvents + */ +public class PublishedEventsAssert extends AbstractAssert { + + /** + * Creates a new {@link PublishedEventsAssert} + * + * @param actual must not be {@literal null}. + */ + PublishedEventsAssert(AssertablePublishedEvents actual) { + super(actual, PublishedEventsAssert.class); + } + + /** + * Asserts that the {@link PublishedEvents} contain at least one event of the given type. Use the returned + * {@link PublishedEventsAssert} to further qualify the assertions. + * + * @param the event type expected + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + */ + public PublishedEventAssert contains(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + var ofType = actual.ofType(type); + + assertThat(ofType).isNotEmpty(); + + return new PublishedEventAssert<>(ofType); + } + + /** + * Assertions further qualifying the expected events. + * + * @author Oliver Drotbohm + */ + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public class PublishedEventAssert { + + private final TypedPublishedEvents events; + + /** + * Asserts that at least one event matches the given predicate. + * + * @param predicate must not be {@literal null}. + * @return will never be {@literal null}. + */ + public PublishedEventAssert matching(Predicate predicate) { + + Assert.notNull(predicate, "Predicate must not be null!"); + + assertThat(events.matching(predicate)).isNotEmpty(); + + return this; + } + + /** + * Asserts that at least one event exists for which the value extracted by the given {@link Function} matches the + * given {@link Predicate}. + * + * @param the type of the value to be matched. + * @param function the extractor function, must not be {@literal null}. + * @param predicate the {@link Predicate} the extracted value is supposed to match. Must not be {@literal null}. + * @return will never be {@literal null}. + */ + public PublishedEventAssert matching(Function function, Predicate predicate) { + + Assert.notNull(function, "Function must not be null!"); + Assert.notNull(predicate, "Predicate must not be null!"); + + assertThat(events.matching(function, predicate)).isNotEmpty(); + + return this; + } + + /** + * Asserts that at least one event exists for which the value extracted by the given {@link Function} matches the + * given one. + * + * @param the type of the value to be matched + * @param function the extractor function, must not be {@literal null}. + * @param value the expected value, can be {@literal null}. + * @return will never be {@literal null}. + */ + public PublishedEventAssert matching(Function function, @Nullable S value) { + + Assert.notNull(function, "Function must not be null!"); + + assertThat(events.matching(function, value)).isNotEmpty(); + + return this; + } + + /** + * Syntactic sugar to start a new assertion on a different type of event. + * + * @return will never be {@literal null}. + */ + public PublishedEventsAssert and() { + return PublishedEventsAssert.this; + } + } +} diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsParameterResolver.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsParameterResolver.java index 1c12cbe1..2a38f101 100644 --- a/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsParameterResolver.java +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/PublishedEventsParameterResolver.java @@ -28,6 +28,7 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** * Provides instances of {@link PublishedEvents} as test method parameters. @@ -36,6 +37,9 @@ import org.springframework.util.Assert; */ class PublishedEventsParameterResolver implements ParameterResolver, BeforeAllCallback, AfterEachCallback { + private static final boolean ASSERT_J_PRESENT = ClassUtils.isPresent("org.assertj.core.api.Assert", + PublishedEventsParameterResolver.class.getClassLoader()); + private ThreadBoundApplicationListenerAdapter listener = new ThreadBoundApplicationListenerAdapter(); private final Function lookup; @@ -64,7 +68,15 @@ class PublishedEventsParameterResolver implements ParameterResolver, BeforeAllCa */ @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { - return PublishedEvents.class.isAssignableFrom(parameterContext.getParameter().getType()); + + var type = parameterContext.getParameter().getType(); + + if (type.getName().equals("org.springframework.modulith.test.AssertablePublishedEvents") && !ASSERT_J_PRESENT) { + throw new IllegalStateException( + "Method declares AssertablePublishedEvents as parameter but AssertJ is not on the classpath!"); + } + + return PublishedEvents.class.isAssignableFrom(type); } /* @@ -74,7 +86,10 @@ class PublishedEventsParameterResolver implements ParameterResolver, BeforeAllCa @Override public PublishedEvents resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { - DefaultPublishedEvents publishedEvents = new DefaultPublishedEvents(); + var publishedEvents = ASSERT_J_PRESENT + ? new DefaultAssertablePublishedEvents() + : new DefaultPublishedEvents(); + listener.registerDelegate(publishedEvents); return publishedEvents; diff --git a/spring-modulith-test/src/test/java/org/springframework/modulith/test/PublishedEventsParameterResolverUnitTests.java b/spring-modulith-test/src/test/java/org/springframework/modulith/test/PublishedEventsParameterResolverUnitTests.java index 1c89acb7..ec727de9 100644 --- a/spring-modulith-test/src/test/java/org/springframework/modulith/test/PublishedEventsParameterResolverUnitTests.java +++ b/spring-modulith-test/src/test/java/org/springframework/modulith/test/PublishedEventsParameterResolverUnitTests.java @@ -36,7 +36,7 @@ import org.springframework.util.ReflectionUtils; * * @author Oliver Drotbohm */ -public class PublishedEventsParameterResolverUnitTests { +class PublishedEventsParameterResolverUnitTests { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); @@ -46,6 +46,7 @@ public class PublishedEventsParameterResolverUnitTests { PublishedEventsParameterResolver resolver = new PublishedEventsParameterResolver(__ -> context); assertThat(resolver.supportsParameter(getParameterContext(PublishedEvents.class), null)).isTrue(); + assertThat(resolver.supportsParameter(getParameterContext(AssertablePublishedEvents.class), null)).isTrue(); assertThat(resolver.supportsParameter(getParameterContext(Object.class), null)).isFalse(); } @@ -99,5 +100,7 @@ public class PublishedEventsParameterResolverUnitTests { void with(PublishedEvents events); void with(Object object); + + void with(AssertablePublishedEvents events); } }