Skip to content

Commit cd76c96

Browse files
committed
GH-165 - Introduce ScenarioCustomizer extension.
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.
1 parent daee88a commit cd76c96

File tree

5 files changed

+296
-3
lines changed

5 files changed

+296
-3
lines changed

spring-modulith-test/src/main/java/org/springframework/modulith/test/Scenario.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ public class Scenario {
7474
private final ApplicationEventPublisher publisher;
7575
private final AssertablePublishedEvents events;
7676

77+
private Function<ConditionFactory, ConditionFactory> defaultCustomizer;
78+
7779
/**
7880
* Creates a new {@link Scenario} for the given {@link TransactionTemplate}, {@link ApplicationEventPublisher} and
7981
* {@link AssertablePublishedEvents}.
@@ -95,6 +97,7 @@ public class Scenario {
9597
this.transactionOperations = new TransactionTemplate(transactionTemplate.getTransactionManager(), definition);
9698
this.publisher = publisher;
9799
this.events = events;
100+
this.defaultCustomizer = Function.identity();
98101
}
99102

100103
/**
@@ -197,7 +200,22 @@ public <S> When<S> stimulate(BiFunction<TransactionOperations, ApplicationEventP
197200

198201
Assert.notNull(stimulus, "Stimulus must not be null!");
199202

200-
return new When<>(stimulus, __ -> {}, Function.identity());
203+
return new When<>(stimulus, __ -> {}, defaultCustomizer);
204+
}
205+
206+
/**
207+
* Extension hook to allow registration of a global customizer. If none configured we will fall back to
208+
* {@link Function#identity()}.
209+
*
210+
* @param customizer must not be {@literal null}.
211+
* @see org.springframework.modulith.test.ScenarioCustomizer
212+
*/
213+
Scenario setDefaultCustomizer(Function<ConditionFactory, ConditionFactory> customizer) {
214+
215+
Assert.notNull(customizer, "Customizer must not be null!");
216+
217+
this.defaultCustomizer = customizer;
218+
return this;
201219
}
202220

203221
public class When<T> {
@@ -263,14 +281,18 @@ public When<T> andWaitAtMost(Duration duration) {
263281
}
264282

265283
/**
284+
* Customize the execution of the scenario. The given customizer will be added to the default one registered via a
285+
* {@link org.springframework.modulith.test.ScenarioCustomizer}. In other words, multiple invocations will replace
286+
* registrations made in previous calls but always be chained after the default customizations registered.
287+
*
266288
* @param customizer must not be {@literal null}.
267289
* @return will never be {@literal null}.
268290
*/
269291
public When<T> customize(Function<ConditionFactory, ConditionFactory> customizer) {
270292

271293
Assert.notNull(customizer, "Customizer must not be null!");
272294

273-
return new When<T>(stimulus, cleanup, customizer);
295+
return new When<T>(stimulus, cleanup, defaultCustomizer.andThen(customizer));
274296
}
275297

276298
// Expect event
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.modulith.test;
17+
18+
import java.lang.reflect.Method;
19+
import java.util.function.Function;
20+
21+
import org.awaitility.core.ConditionFactory;
22+
import org.junit.jupiter.api.extension.ExtensionContext;
23+
import org.junit.jupiter.api.extension.InvocationInterceptor;
24+
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
25+
import org.springframework.context.ApplicationContext;
26+
import org.springframework.test.context.junit.jupiter.SpringExtension;
27+
import org.springframework.util.Assert;
28+
29+
/**
30+
* A JUnit {@link InvocationInterceptor} to register a default customizer to be applied to all {@link Scenario}
31+
* instances associated with that test case.
32+
*
33+
* @author Oliver Drotbohm
34+
*/
35+
public interface ScenarioCustomizer extends InvocationInterceptor {
36+
37+
/**
38+
* Return a customizer to be applied to the {@link Scenario} instance handed into the given method.
39+
*
40+
* @param method will never be {@literal null}.
41+
* @param context will never be {@literal null}.
42+
* @return must not be {@literal null}.
43+
*/
44+
Function<ConditionFactory, ConditionFactory> getDefaultCustomizer(Method method, ApplicationContext context);
45+
46+
/*
47+
* (non-Javadoc)
48+
* @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)
49+
*/
50+
@Override
51+
default void interceptTestTemplateMethod(Invocation<Void> invocation,
52+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
53+
54+
prepareScenarioInstance(invocationContext, extensionContext);
55+
56+
invocation.proceed();
57+
}
58+
59+
/*
60+
* (non-Javadoc)
61+
* @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)
62+
*/
63+
@Override
64+
default <T> T interceptTestFactoryMethod(Invocation<T> invocation,
65+
ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
66+
67+
prepareScenarioInstance(invocationContext, extensionContext);
68+
69+
return invocation.proceed();
70+
}
71+
72+
/*
73+
* (non-Javadoc)
74+
* @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)
75+
*/
76+
@Override
77+
default void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
78+
ExtensionContext extensionContext) throws Throwable {
79+
80+
prepareScenarioInstance(invocationContext, extensionContext);
81+
82+
invocation.proceed();
83+
}
84+
85+
private void prepareScenarioInstance(ReflectiveInvocationContext<Method> invocationContext,
86+
ExtensionContext extensionContext) {
87+
88+
invocationContext.getArguments().stream()
89+
.filter(Scenario.class::isInstance)
90+
.map(Scenario.class::cast)
91+
.forEach(it -> {
92+
93+
var context = SpringExtension.getApplicationContext(extensionContext);
94+
var customizer = getDefaultCustomizer(invocationContext.getExecutable(), context);
95+
Assert.state(customizer != null, "Customizer must not be null!");
96+
97+
it.setDefaultCustomizer(customizer);
98+
});
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.modulith.test;
17+
18+
import static org.assertj.core.api.Assertions.*;
19+
import static org.mockito.Mockito.*;
20+
21+
import java.lang.reflect.Method;
22+
import java.util.function.Function;
23+
24+
import org.awaitility.core.ConditionFactory;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.extension.ExtendWith;
28+
import org.springframework.context.ApplicationContext;
29+
import org.springframework.context.annotation.Bean;
30+
import org.springframework.context.annotation.Configuration;
31+
import org.springframework.modulith.test.ScenarioCustomizerIntegrationTests.TestScenarioCustomizer;
32+
import org.springframework.test.context.ContextConfiguration;
33+
import org.springframework.test.context.junit.jupiter.SpringExtension;
34+
import org.springframework.test.util.ReflectionTestUtils;
35+
import org.springframework.transaction.support.TransactionTemplate;
36+
37+
/**
38+
* Integration tests for {@link ScenarioCustomizer}.
39+
*
40+
* @author Oliver Drotbohm
41+
*/
42+
@ExtendWith({ SpringExtension.class, ScenarioParameterResolver.class, TestScenarioCustomizer.class })
43+
@ContextConfiguration
44+
class ScenarioCustomizerIntegrationTests {
45+
46+
@Configuration
47+
static class TestConfiguration {
48+
49+
@Bean
50+
TransactionTemplate transactionTemplate() {
51+
return mock(TransactionTemplate.class);
52+
}
53+
}
54+
55+
@BeforeEach
56+
void setUp() {
57+
TestScenarioCustomizer.invoked = false;
58+
}
59+
60+
@Test // GH-165
61+
void customizerGetsAppliedForScenarioParameter(Scenario scenario) {
62+
63+
assertThat(TestScenarioCustomizer.invoked).isTrue();
64+
assertThat(ReflectionTestUtils.getField(scenario, "defaultCustomizer"))
65+
.isSameAs(TestScenarioCustomizer.SAMPLE);
66+
}
67+
68+
@Test // GH-165
69+
void customizerDoesNotGetAppliedForNoScenarioParameter() {
70+
assertThat(TestScenarioCustomizer.invoked).isFalse();
71+
}
72+
73+
static class TestScenarioCustomizer implements ScenarioCustomizer {
74+
75+
static Function<ConditionFactory, ConditionFactory> SAMPLE = it -> it;
76+
static boolean invoked = false;
77+
78+
@Override
79+
public Function<ConditionFactory, ConditionFactory> getDefaultCustomizer(Method method,
80+
ApplicationContext context) {
81+
82+
invoked = true;
83+
84+
return SAMPLE;
85+
}
86+
}
87+
}

spring-modulith-test/src/test/java/org/springframework/modulith/test/ScenarioUnitTests.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
import java.time.Duration;
2626
import java.util.function.BiConsumer;
2727
import java.util.function.Consumer;
28+
import java.util.function.Function;
2829
import java.util.function.Supplier;
2930

31+
import org.awaitility.core.ConditionFactory;
3032
import org.junit.jupiter.api.BeforeEach;
3133
import org.junit.jupiter.api.Test;
3234
import org.junit.jupiter.api.extension.ExtendWith;
@@ -347,6 +349,39 @@ void executesStimulusInNewTransaction() {
347349
.getTransaction(argThat(it -> it.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW));
348350
}
349351

352+
@Test // GH-165
353+
void chainsSpecialCustomizerBehindDefaultOne() {
354+
355+
var defaultCustomizer = new InvocationTracingCustomizer();
356+
var specialCustomizer = new InvocationTracingCustomizer();
357+
358+
new Scenario(tx, publisher, new DefaultAssertablePublishedEvents())
359+
.setDefaultCustomizer(defaultCustomizer)
360+
.publish(new Object())
361+
.customize(specialCustomizer)
362+
.andWaitForStateChange(() -> true);
363+
364+
assertThat(defaultCustomizer.invoked).isTrue();
365+
assertThat(specialCustomizer.invoked).isTrue();
366+
}
367+
368+
@Test // GH-165
369+
void replacesSpecialCustomizerBehindDefaultOne() {
370+
371+
var defaultCustomizer = new InvocationTracingCustomizer();
372+
var specialCustomizer = new InvocationTracingCustomizer();
373+
374+
new Scenario(tx, publisher, new DefaultAssertablePublishedEvents())
375+
.setDefaultCustomizer(defaultCustomizer)
376+
.publish(new Object())
377+
.customize(specialCustomizer)
378+
.customize(Function.identity())
379+
.andWaitForStateChange(() -> true);
380+
381+
assertThat(defaultCustomizer.invoked).isTrue();
382+
assertThat(specialCustomizer.invoked).isFalse();
383+
}
384+
350385
private Fixture givenAScenario(Consumer<Scenario> consumer) {
351386
return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents());
352387
}
@@ -467,4 +502,17 @@ public void throwIfCaught() {
467502
throw new RuntimeException(caught);
468503
}
469504
}
505+
506+
static class InvocationTracingCustomizer implements Function<ConditionFactory, ConditionFactory> {
507+
508+
boolean invoked = false;
509+
510+
@Override
511+
public ConditionFactory apply(ConditionFactory t) {
512+
513+
invoked = true;
514+
515+
return t;
516+
}
517+
}
470518
}

src/docs/asciidoc/30-testing.adoc

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ If you find your application module depending on too many beans of other ones, t
7979
The dependencies should be reviewed for whether they are candidates for replacement by publishing a domain event (see <<events>>).
8080

8181
[[testing.scenarios]]
82-
== Defining integration test scenarios
82+
== Defining Integration Test Scenarios
8383

8484
Integration testing application modules can become a quite elaborate effort.
8585
Especially if the integration of those is based on <<events.aml, asynchronous, transactional event handling>>, 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
178178
By default, non-`null` values and non-empty ``Optional``s will be considered a conclusive state change.
179179
This can be tweaked by using the `….andWaitForStateChange(…, Predicate)` overload.
180180

181+
[[testing.scenarios.customize]]
182+
=== Customizing Scenario Execution
181183

184+
To customize the execution of an individual scenario, call the `….customize(…)` method in the setup chain of the `Scenario`:
185+
186+
.Customizing a `Scenario` execution
187+
[source, java, subs="+quotes"]
188+
----
189+
scenario.publish(new MyApplicationEvent(…))
190+
**.customize(it -> it.atMost(Duration.ofSeconds(2)))**
191+
.andWaitForEventOfType(SomeOtherEvent.class)
192+
.matching(event -> …)
193+
.toArriveAndVerify(event -> …);
194+
----
195+
196+
To globally customize all `Scenario` instances of a test class, implement a `ScenarioCustomizer` and register it as JUnit extension.
197+
198+
.Registering a `ScenarioCustomizer`
199+
[source, java]
200+
----
201+
@ExtendWith(MyCustomizer.class)
202+
class MyTests {
203+
204+
@Test
205+
void myTestCase(Scenario scenario) {
206+
// scenario will be pre-customized with logic defined in MyCustomizer
207+
}
208+
209+
static class MyCustomizer implements ScenarioCustomizer {
210+
211+
@Override
212+
Function<ConditionFactory, ConditionFactory> getDefaultCustomizer(Method method, ApplicationContext context) {
213+
return it -> …;
214+
}
215+
}
216+
}
217+
----

0 commit comments

Comments
 (0)