For the latest stable version, please use Spring Modulith 1.2.3! |
Integration Testing Application Modules
Spring Modulith allows to run integration tests bootstrapping individual application modules in isolation or combination with others.
To achieve this, place JUnit test class in an application module package or any sub-package of that and annotate it with @ApplicationModuleTest
:
-
Java
-
Kotlin
package example.order;
@ApplicationModuleTest
class OrderIntegrationTests {
// Individual test cases go here
}
package example.order
@ApplicationModuleTest
class OrderIntegrationTests {
// Individual test cases go here
}
This will run your integration test similar to what @SpringBootTest
would have achieved but with the bootstrap actually limited to the application module the test resides in.
If you configure the log level for org.springframework.modulith
to DEBUG
, you will see detailed information about how the test execution customizes the Spring Boot bootstrap:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.0.0-SNAPSHOT)
… - Bootstrapping @ApplicationModuleTest for example.order in mode STANDALONE (class example.Application)…
… - ======================================================================================================
… - ## example.order ##
… - > Logical name: order
… - > Base package: example.order
… - > Direct module dependencies: none
… - > Spring beans:
… - + ….OrderManagement
… - + ….internal.OrderInternal
… - Starting OrderIntegrationTests using Java 17.0.3 …
… - No active profile set, falling back to 1 default profile: "default"
… - Re-configuring auto-configuration and entity scan packages to: example.order.
Note, how the output contains the detailed information about the module included in the test run. It creates the application module module, finds the module to be run and limits the application of auto-configuration, component and entity scanning to the corresponding packages.
Bootstrap Modes
The application module test can be bootstrapped in a variety of modes:
-
STANDALONE
(default) — Runs the current module only. -
DIRECT_DEPENDENCIES
— Runs the current module as well as all modules the current one directly depends on. -
ALL_DEPENDENCIES
— Runs the current module and the entire tree of modules depended on.
Dealing with Efferent Dependencies
When an application module is bootstrapped, the Spring beans it contains will be instantiated. If those contain bean references that cross module boundaries, the bootstrap will fail if those other modules are not included in the test run (see Bootstrap Modes for details). While a natural reaction might be to expand the scope of the application modules included, it is usually a better option to mock the target beans.
-
Java
-
Kotlin
@ApplicationModuleTest
class InventoryIntegrationTests {
@MockBean SomeOtherComponent someOtherComponent;
}
@ApplicationModuleTest
class InventoryIntegrationTests {
@MockBean SomeOtherComponent someOtherComponent
}
Spring Boot will create bean definitions and instances for the types defined as @MockBean
and add them to the ApplicationContext
bootstrapped for the test run.
If you find your application module depending on too many beans of other ones, that is usually a sign of high coupling between them. The dependencies should be reviewed for whether they are candidates for replacement by publishing a domain event (see null).
Defining Integration Test Scenarios
Integration testing application modules can become a quite elaborate effort.
Especially if the integration of those is based on asynchronous, transactional event handling, dealing with the concurrent execution can be subject to subtle errors.
Also, it requires dealing with quite a few infrastructure components: TransactionOperations
and ApplicationEventProcessor
to make sure events are published and delivered to transactional listeners, Awaitility to handle concurrency and AssertJ assertions to formulate expectations on the test execution’s outcome.
To ease the definition of application module integration tests, Spring Modulith provides the Scenario
abstraction that can be used by declaring it as test method parameter in tests declared as @ApplicationModuleTest
.
Scenario
API in a JUnit 5 test-
Java
-
Kotlin
@ApplicationModuleTest
class SomeApplicationModuleTest {
@Test
public void someModuleIntegrationTest(Scenario scenario) {
// Use the Scenario API to define your integration test
}
}
@ApplicationModuleTest
class SomeApplicationModuleTest {
@Test
fun someModuleIntegrationTest(scenario: Scenario) {
// Use the Scenario API to define your integration test
}
}
The test definition itself usually follows the following skeleton:
-
A stimulus to the system is defined. This is usually either an event publication or an invocation of a Spring component exposed by the module.
-
Optional customization of technical details of the execution (timeouts, etc.)
-
The definition of some expected outcome, such as another application event being fired that matches some criteria or some state change of the module that can be detected by invoking exposed components.
-
Optional, additional verifications made on the received event or observed, changed state.
Scenario
exposes API to define these steps and guide you through the definition.
Scenario
-
Java
-
Kotlin
// Start with an event publication
scenario.publish(new MyApplicationEvent(…)).…
// Start with a bean invocation
scenario.stimulate(() -> someBean.someMethod(…)).…
// Start with an event publication
scenario.publish(MyApplicationEvent(…)).…
// Start with a bean invocation
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. This can be an event of a particular type in turn, optionally further constraint by matchers:
-
Java
-
Kotlin
….andWaitForEventOfType(SomeOtherEvent.class)
.matching(event -> …) // Use some predicate here
.…
….andWaitForEventOfType(SomeOtherEvent.class)
.matching(event -> …) // Use some predicate here
.…
These lines set up a completion criteria that the eventual execution will wait for to proceed.
In other words, the example above will cause the execution to eventually block until either the default timeout is reached or a SomeOtherEvent
is published that matches the predicate defined.
The terminal operations to execute the event-based Scenario
are named ….toArrive…()
and allow to optionally access the expected event published, or the result object of the bean invocation defined in the original stimulus.
-
Java
-
Kotlin
// Executes the scenario
….toArrive(…)
// Execute and define assertions on the event received
….toArriveAndVerify(event -> …)
// Executes the scenario
….toArrive(…)
// Execute and define assertions on the event received
….toArriveAndVerify(event -> …)
The choice of method names might look a bit weird when looking at the steps individually but they actually read quite fluent when combined.
Scenario
definition-
Java
-
Kotlin
scenario.publish(new MyApplicationEvent(…))
.andWaitForEventOfType(SomeOtherEvent.class)
.matching(event -> …)
.toArriveAndVerify(event -> …);
scenario.publish(new MyApplicationEvent(…))
.andWaitForEventOfType(SomeOtherEvent::class)
.matching(event -> …)
.toArriveAndVerify(event -> …)
Alternatively to an event publication acting as expected completion signal, we can also inspect the state of the application module by invoking a method on one of the components exposed. The scenario would then rather look like this:
-
Java
-
Kotlin
scenario.publish(new MyApplicationEvent(…))
.andWaitForStateChange(() -> someBean.someMethod(…)))
.andVerify(result -> …);
scenario.publish(new MyApplicationEvent(…))
.andWaitForStateChange(() -> someBean.someMethod(…)))
.andVerify(result -> …)
The result
handed into the ….andVerify(…)
method will be the value returned by the method invocation to detect the state change.
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.
Customizing Scenario Execution
To customize the execution of an individual scenario, call the ….customize(…)
method in the setup chain of the Scenario
:
Scenario
execution-
Java
-
Kotlin
scenario.publish(new MyApplicationEvent(…))
.customize(it -> it.atMost(Duration.ofSeconds(2)))
.andWaitForEventOfType(SomeOtherEvent.class)
.matching(event -> …)
.toArriveAndVerify(event -> …);
scenario.publish(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.
ScenarioCustomizer
-
Java
-
Kotlin
@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<ConditionFactory, ConditionFactory> getDefaultCustomizer(Method method, ApplicationContext context) {
return it -> …;
}
}
}
@ExtendWith(MyCustomizer::class)
class MyTests {
@Test
fun myTestCase(scenario : Scenario) {
// scenario will be pre-customized with logic defined in MyCustomizer
}
class MyCustomizer : ScenarioCustomizer {
override fun getDefaultCustomizer(method : Method, context : ApplicationContext) : Function<ConditionFactory, ConditionFactory> {
return it -> …
}
}
}