From ce264aaf96f18db1785b3e40b10e7d7440c3e2d4 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Sun, 26 Feb 2023 20:26:53 +0100 Subject: [PATCH] GH-150 - Scenario now execute stimulus within new transaction. To make sure that we trigger transactional event listeners from Scenario based integration tests, we trigger stimulus executions using transactions configured to require a new transaction. This makes sure that Scenarios work in integration tests using @Transactional, of course circumventing the default rollback behavior of those transactions. There's no other way to implement this as we *need* to commit a transaction to trigger the delivery of transaction-bound events. Fixed a bug in the handling of Scenario.stimulus(Supplier) as for this, the Supplier handed into the method had not been executed transactionally at all. --- .../modulith/test/Scenario.java | 18 ++++--- .../test/ScenarioParameterResolver.java | 4 +- .../modulith/test/ScenarioUnitTests.java | 49 ++++++++++--------- src/docs/asciidoc/30-testing.adoc | 4 ++ 4 files changed, 45 insertions(+), 30 deletions(-) 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 ec48cf57..b8028342 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 @@ -34,7 +34,10 @@ 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.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.transaction.support.TransactionOperations; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.Assert; import com.tngtech.archunit.thirdparty.com.google.common.base.Optional; @@ -72,21 +75,24 @@ public class Scenario { private final AssertablePublishedEvents events; /** - * Creates a new {@link Scenario} for the given {@link TransactionOperations}, {@link ApplicationEventPublisher} and + * Creates a new {@link Scenario} for the given {@link TransactionTemplate}, {@link ApplicationEventPublisher} and * {@link AssertablePublishedEvents}. * - * @param transactionOperations must not be {@literal null}. + * @param transactionTemplate must not be {@literal null}. * @param publisher must not be {@literal null}. * @param events must not be {@literal null}. */ - Scenario(TransactionOperations transactionOperations, ApplicationEventPublisher publisher, + Scenario(TransactionTemplate transactionTemplate, ApplicationEventPublisher publisher, AssertablePublishedEvents events) { - Assert.notNull(transactionOperations, "TransactionOperations must not be null!"); + Assert.notNull(transactionTemplate, "TransactionTemplate must not be null!"); Assert.notNull(publisher, "ApplicationEventPublisher must not be null!"); Assert.notNull(events, "AssertablePublishedEvents must not be null!"); - this.transactionOperations = transactionOperations; + var definition = new DefaultTransactionDefinition(transactionTemplate); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + this.transactionOperations = new TransactionTemplate(transactionTemplate.getTransactionManager(), definition); this.publisher = publisher; this.events = events; } @@ -145,7 +151,7 @@ public class Scenario { * @see EventResult#toArriveAndVerify(Consumer) */ public When stimulate(Supplier supplier) { - return stimulate(__ -> supplier.get()); + return stimulate(tx -> tx.execute(__ -> supplier.get())); } /** 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 index a9bc40a2..41909a48 100644 --- 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 @@ -21,7 +21,7 @@ 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; +import org.springframework.transaction.support.TransactionTemplate; /** * JUnit {@link ParameterResolver} for {@link Scenario}. @@ -70,7 +70,7 @@ class ScenarioParameterResolver implements ParameterResolver, BeforeAllCallback throws ParameterResolutionException { var context = SpringExtension.getApplicationContext(extensionContext); - var operations = context.getBean(TransactionOperations.class); + var operations = context.getBean(TransactionTemplate.class); var events = (AssertablePublishedEvents) delegate.resolveParameter(parameterContext, extensionContext); return new Scenario(operations, context, events); 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 1c696699..86b3f59e 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 @@ -27,6 +27,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -35,10 +36,9 @@ 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.TransactionException; -import org.springframework.transaction.support.SimpleTransactionStatus; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionOperations; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; /** * Unit tests for {@link Scenario}. @@ -52,8 +52,15 @@ class ScenarioUnitTests { private static final Duration WAIT_TIME = Duration.ofMillis(101); private static final Duration TIMED_OUT = Duration.ofMillis(150); + TransactionTemplate tx; + @Mock ApplicationEventPublisher publisher; - TransactionOperations tx = StubTransactionOperations.INSTANCE; + @Mock PlatformTransactionManager txManager; + + @BeforeEach + void setUp() { + this.tx = new TransactionTemplate(txManager); + } @Test // GH-136 void timesOutIfNoEventArrivesInTime() throws Throwable { @@ -328,6 +335,18 @@ class ScenarioUnitTests { verify(verification, never()).accept(any(), any()); } + @Test // GH-150 + void executesStimulusInNewTransaction() { + + Consumer scenario = it -> it.stimulate(() -> 41) + .andWaitForStateChange(() -> true); + + givenAScenario(scenario).expectSuccess(); + + verify(txManager) + .getTransaction(argThat(it -> it.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW)); + } + private Fixture givenAScenario(Consumer consumer) { return new Fixture(consumer, DELAY, null, new DefaultAssertablePublishedEvents()); } @@ -377,21 +396,21 @@ class ScenarioUnitTests { public void expectSuccess() { - foo(); + execute(); exceptionHandler.throwIfCaught(); } public void expectFailure() { - foo(); + execute(); assertThat(exceptionHandler.caught) .as("Expected failure but did not see an exception!") .isNotNull(); } - private void foo() { + private void execute() { try { @@ -422,20 +441,6 @@ class ScenarioUnitTests { record SomeEvent(String payload) {} - enum StubTransactionOperations implements TransactionOperations { - - INSTANCE; - - /* - * (non-Javadoc) - * @see org.springframework.transaction.support.TransactionOperations#execute(org.springframework.transaction.support.TransactionCallback) - */ - @Override - public T execute(TransactionCallback action) throws TransactionException { - return action.doInTransaction(new SimpleTransactionStatus()); - } - } - static class CapturingExceptionHandler implements UncaughtExceptionHandler { Throwable caught; diff --git a/src/docs/asciidoc/30-testing.adoc b/src/docs/asciidoc/30-testing.adoc index 138bc67c..8e47e496 100644 --- a/src/docs/asciidoc/30-testing.adoc +++ b/src/docs/asciidoc/30-testing.adoc @@ -120,6 +120,10 @@ scenario.stimulate(() -> someBean.someMethod(…)).… ---- Both the event publication and bean invocation will happen within a transaction callback to make sure the given event or any ones published during the bean invocation will be delivered to transactional event listeners. +Note, that this will require a *new* transaction to be started, no matter whether the test case is already running inside a transaction or not. +In other words, state changes of the database triggered by the stimulus will *never* be rolled back and have to be cleaned up manually. +See the `….andCleanup(…)` methods for that purpose. + The resulting object can now get the execution customized though the generic `….customize(…)` method or specialized ones for common use cases like setting a timeout (`….waitAtMost(…)`). The setup phase will be concluded by defining the actual expectation of the outcome of the stimulus.