From cd76c962505444a523d7ea304ba97dd4e706735e Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Thu, 20 Apr 2023 14:15:10 +0200 Subject: [PATCH] GH-165 - Introduce ScenarioCustomizer extension. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now provide a ScenarioCustomizer that can be used to prepare Scenario instances for test methods with a common customizer. This avoids the need to call ….customize(…) for all Scenarios declared in a test class with the same logic. --- .../modulith/test/Scenario.java | 26 ++++- .../modulith/test/ScenarioCustomizer.java | 100 ++++++++++++++++++ .../ScenarioCustomizerIntegrationTests.java | 87 +++++++++++++++ .../modulith/test/ScenarioUnitTests.java | 48 +++++++++ src/docs/asciidoc/30-testing.adoc | 38 ++++++- 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioCustomizer.java create mode 100644 spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioCustomizerIntegrationTests.java 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 index b8028342..311981af 100644 --- 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 @@ -74,6 +74,8 @@ public class Scenario { private final ApplicationEventPublisher publisher; private final AssertablePublishedEvents events; + private Function defaultCustomizer; + /** * Creates a new {@link Scenario} for the given {@link TransactionTemplate}, {@link ApplicationEventPublisher} and * {@link AssertablePublishedEvents}. @@ -95,6 +97,7 @@ public class Scenario { this.transactionOperations = new TransactionTemplate(transactionTemplate.getTransactionManager(), definition); this.publisher = publisher; this.events = events; + this.defaultCustomizer = Function.identity(); } /** @@ -197,7 +200,22 @@ public class Scenario { Assert.notNull(stimulus, "Stimulus must not be null!"); - return new When<>(stimulus, __ -> {}, Function.identity()); + return new When<>(stimulus, __ -> {}, defaultCustomizer); + } + + /** + * Extension hook to allow registration of a global customizer. If none configured we will fall back to + * {@link Function#identity()}. + * + * @param customizer must not be {@literal null}. + * @see org.springframework.modulith.test.ScenarioCustomizer + */ + Scenario setDefaultCustomizer(Function customizer) { + + Assert.notNull(customizer, "Customizer must not be null!"); + + this.defaultCustomizer = customizer; + return this; } public class When { @@ -263,6 +281,10 @@ public class Scenario { } /** + * Customize the execution of the scenario. The given customizer will be added to the default one registered via a + * {@link org.springframework.modulith.test.ScenarioCustomizer}. In other words, multiple invocations will replace + * registrations made in previous calls but always be chained after the default customizations registered. + * * @param customizer must not be {@literal null}. * @return will never be {@literal null}. */ @@ -270,7 +292,7 @@ public class Scenario { Assert.notNull(customizer, "Customizer must not be null!"); - return new When(stimulus, cleanup, customizer); + return new When(stimulus, cleanup, defaultCustomizer.andThen(customizer)); } // Expect event diff --git a/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioCustomizer.java b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioCustomizer.java new file mode 100644 index 00000000..abdddac9 --- /dev/null +++ b/spring-modulith-test/src/main/java/org/springframework/modulith/test/ScenarioCustomizer.java @@ -0,0 +1,100 @@ +/* + * 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.lang.reflect.Method; +import java.util.function.Function; + +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.Assert; + +/** + * A JUnit {@link InvocationInterceptor} to register a default customizer to be applied to all {@link Scenario} + * instances associated with that test case. + * + * @author Oliver Drotbohm + */ +public interface ScenarioCustomizer extends InvocationInterceptor { + + /** + * Return a customizer to be applied to the {@link Scenario} instance handed into the given method. + * + * @param method will never be {@literal null}. + * @param context will never be {@literal null}. + * @return must not be {@literal null}. + */ + Function getDefaultCustomizer(Method method, ApplicationContext context); + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.InvocationInterceptor#interceptTestTemplateMethod(org.junit.jupiter.api.extension.InvocationInterceptor.Invocation, org.junit.jupiter.api.extension.ReflectiveInvocationContext, org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + default void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + + prepareScenarioInstance(invocationContext, extensionContext); + + invocation.proceed(); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.InvocationInterceptor#interceptTestFactoryMethod(org.junit.jupiter.api.extension.InvocationInterceptor.Invocation, org.junit.jupiter.api.extension.ReflectiveInvocationContext, org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + default T interceptTestFactoryMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + + prepareScenarioInstance(invocationContext, extensionContext); + + return invocation.proceed(); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.InvocationInterceptor#interceptTestMethod(org.junit.jupiter.api.extension.InvocationInterceptor.Invocation, org.junit.jupiter.api.extension.ReflectiveInvocationContext, org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + default void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + + prepareScenarioInstance(invocationContext, extensionContext); + + invocation.proceed(); + } + + private void prepareScenarioInstance(ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) { + + invocationContext.getArguments().stream() + .filter(Scenario.class::isInstance) + .map(Scenario.class::cast) + .forEach(it -> { + + var context = SpringExtension.getApplicationContext(extensionContext); + var customizer = getDefaultCustomizer(invocationContext.getExecutable(), context); + Assert.state(customizer != null, "Customizer must not be null!"); + + it.setDefaultCustomizer(customizer); + }); + } +} diff --git a/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioCustomizerIntegrationTests.java b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioCustomizerIntegrationTests.java new file mode 100644 index 00000000..072693ce --- /dev/null +++ b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioCustomizerIntegrationTests.java @@ -0,0 +1,87 @@ +/* + * 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.Mockito.*; + +import java.lang.reflect.Method; +import java.util.function.Function; + +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.modulith.test.ScenarioCustomizerIntegrationTests.TestScenarioCustomizer; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration tests for {@link ScenarioCustomizer}. + * + * @author Oliver Drotbohm + */ +@ExtendWith({ SpringExtension.class, ScenarioParameterResolver.class, TestScenarioCustomizer.class }) +@ContextConfiguration +class ScenarioCustomizerIntegrationTests { + + @Configuration + static class TestConfiguration { + + @Bean + TransactionTemplate transactionTemplate() { + return mock(TransactionTemplate.class); + } + } + + @BeforeEach + void setUp() { + TestScenarioCustomizer.invoked = false; + } + + @Test // GH-165 + void customizerGetsAppliedForScenarioParameter(Scenario scenario) { + + assertThat(TestScenarioCustomizer.invoked).isTrue(); + assertThat(ReflectionTestUtils.getField(scenario, "defaultCustomizer")) + .isSameAs(TestScenarioCustomizer.SAMPLE); + } + + @Test // GH-165 + void customizerDoesNotGetAppliedForNoScenarioParameter() { + assertThat(TestScenarioCustomizer.invoked).isFalse(); + } + + static class TestScenarioCustomizer implements ScenarioCustomizer { + + static Function SAMPLE = it -> it; + static boolean invoked = false; + + @Override + public Function getDefaultCustomizer(Method method, + ApplicationContext context) { + + invoked = true; + + return SAMPLE; + } + } +} diff --git a/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java index 86b3f59e..890d51eb 100644 --- a/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java +++ b/spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java @@ -25,8 +25,10 @@ import java.lang.Thread.UncaughtExceptionHandler; import java.time.Duration; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; +import org.awaitility.core.ConditionFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -347,6 +349,39 @@ class ScenarioUnitTests { .getTransaction(argThat(it -> it.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW)); } + @Test // GH-165 + void chainsSpecialCustomizerBehindDefaultOne() { + + var defaultCustomizer = new InvocationTracingCustomizer(); + var specialCustomizer = new InvocationTracingCustomizer(); + + new Scenario(tx, publisher, new DefaultAssertablePublishedEvents()) + .setDefaultCustomizer(defaultCustomizer) + .publish(new Object()) + .customize(specialCustomizer) + .andWaitForStateChange(() -> true); + + assertThat(defaultCustomizer.invoked).isTrue(); + assertThat(specialCustomizer.invoked).isTrue(); + } + + @Test // GH-165 + void replacesSpecialCustomizerBehindDefaultOne() { + + var defaultCustomizer = new InvocationTracingCustomizer(); + var specialCustomizer = new InvocationTracingCustomizer(); + + new Scenario(tx, publisher, new DefaultAssertablePublishedEvents()) + .setDefaultCustomizer(defaultCustomizer) + .publish(new Object()) + .customize(specialCustomizer) + .customize(Function.identity()) + .andWaitForStateChange(() -> true); + + assertThat(defaultCustomizer.invoked).isTrue(); + assertThat(specialCustomizer.invoked).isFalse(); + } + private Fixture givenAScenario(Consumer consumer) { return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents()); } @@ -467,4 +502,17 @@ class ScenarioUnitTests { throw new RuntimeException(caught); } } + + static class InvocationTracingCustomizer implements Function { + + boolean invoked = false; + + @Override + public ConditionFactory apply(ConditionFactory t) { + + invoked = true; + + return t; + } + } } diff --git a/src/docs/asciidoc/30-testing.adoc b/src/docs/asciidoc/30-testing.adoc index 8e47e496..b548f042 100644 --- a/src/docs/asciidoc/30-testing.adoc +++ b/src/docs/asciidoc/30-testing.adoc @@ -79,7 +79,7 @@ If you find your application module depending on too many beans of other ones, t The dependencies should be reviewed for whether they are candidates for replacement by publishing a domain event (see <>). [[testing.scenarios]] -== Defining integration test scenarios +== Defining Integration Test Scenarios Integration testing application modules can become a quite elaborate effort. Especially if the integration of those is based on <>, dealing with the concurrent execution can be subject to subtle errors. @@ -178,4 +178,40 @@ The `result` handed into the `….andVerify(…)` method will be the value retur By default, non-`null` values and non-empty ``Optional``s will be considered a conclusive state change. This can be tweaked by using the `….andWaitForStateChange(…, Predicate)` overload. +[[testing.scenarios.customize]] +=== Customizing Scenario Execution +To customize the execution of an individual scenario, call the `….customize(…)` method in the setup chain of the `Scenario`: + +.Customizing a `Scenario` execution +[source, java, subs="+quotes"] +---- +scenario.publish(new MyApplicationEvent(…)) + **.customize(it -> it.atMost(Duration.ofSeconds(2)))** + .andWaitForEventOfType(SomeOtherEvent.class) + .matching(event -> …) + .toArriveAndVerify(event -> …); +---- + +To globally customize all `Scenario` instances of a test class, implement a `ScenarioCustomizer` and register it as JUnit extension. + +.Registering a `ScenarioCustomizer` +[source, java] +---- +@ExtendWith(MyCustomizer.class) +class MyTests { + + @Test + void myTestCase(Scenario scenario) { + // scenario will be pre-customized with logic defined in MyCustomizer + } + + static class MyCustomizer implements ScenarioCustomizer { + + @Override + Function getDefaultCustomizer(Method method, ApplicationContext context) { + return it -> …; + } + } +} +----