Integration Testing Application Modules

Spring Modulith 允许运行集成测试来孤立或与其他测试相结合地引导单个应用程序模块。为此,将 JUnit 测试类放在应用程序模块包或该类的任何子包中,并使用`@ApplicationModuleTest`对其进行注解:

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:

A application module integration test class
  • Java

  • Kotlin

@ApplicationModuleTest
class OrderIntegrationTests {

  // Individual test cases go here
}
package example.order

@ApplicationModuleTest
class OrderIntegrationTests {

  // Individual test cases go here
}

这将运行与`@SpringBootTest`实现类似的集成测试,但引导实际上仅限于测试所在的应用程序模块。如果你将`org.springframework.modulith`的日志级别配置为`DEBUG`,你将看到有关测试执行如何自定义 Spring Boot 引导的详细信息:

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:

The log output of a application module integration test 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

当启动应用程序模块时,其中包含的 Spring Bean 将被实例化。如果这些 Bean 包含跨越模块边界的 Bean 引用,则当这些其他模块未包含在测试运行中时,启动将会失败(有关详细信息,参见 Bootstrap Modes)。虽然自然反应可能是扩展所包含应用程序模块的范围,但通常更好的选择是对目标 Bean 进行模拟。

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.

Mocking Spring bean dependencies in other application modules
  • Java

  • Kotlin

@ApplicationModuleTest
class InventoryIntegrationTests {

  @MockBean SomeOtherComponent someOtherComponent;
}
@ApplicationModuleTest
class InventoryIntegrationTests {

  @MockBean SomeOtherComponent someOtherComponent
}

Spring Boot 将为定义为`@MockBean`的类型创建 Bean 定义和实例,并将它们添加到为测试运行引导的`ApplicationContext`中。

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.

如果你发现你的应用程序模块依赖于太多其他 Bean,这通常表明它们之间耦合度很高。应该检查依赖项是否有资格通过发布 domain events 来替代。

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 domain events.

Defining Integration Test Scenarios

集成测试应用程序模块可能会变成一个非常精细的工作。特别是如果这些集成基于 asynchronous, transactional event handling,处理并发执行可能会出现微妙的错误。此外,还需要处理相当多的基础设施组件: TransactionOperationsApplicationEventProcessor 以确保发布事件并将其传递给事务监听器,Awaitility 来处理并发,以及 AssertJ 断言来制定对测试执行结果的期望。

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.

为了简化应用模块集成测试的定义,Spring Modulith 提供了 Scenario 抽象,可通过将其声明为 @ApplicationModuleTest 中声明的测试中的测试方法参数来使用。

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.

Using the 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:

  1. 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.

  2. Optional customization of technical details of the execution (timeouts, etc.)

  3. 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.

  4. Optional, additional verifications made on the received event or observed, changed state.

Scenario 公开了 API,用于定义这些步骤并指导你完成定义。

Scenario exposes API to define these steps and guide you through the definition.

Defining a stimulus as starting point of the 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(…)).…

事件发布和 Bean 调用都将发生在事务回调中,以确保给定的事件或在 Bean 调用过程中发布的任何事件都会传送到事务事件侦听器。请注意,无论测试用例是否已经在事务内运行,这都需要启动一个*新*事务。换句话说,由刺激引发的数据库状态更改*永远*不会回滚,必须手动对其进行清理。请参阅 ….andCleanup(…) 方法了解其用途。

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.

现在可以使用通用 ….customize(…) 方法或用于常见用例(例如设置超时 (….waitAtMost(…))的专门方法,来对生成的对象自定义执行。

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:

Expecting an event being published as operation result
  • Java

  • Kotlin

….andWaitForEventOfType(SomeOtherEvent.class)
 .matching(event -> …) // Use some predicate here
 .…
….andWaitForEventOfType(SomeOtherEvent.class)
 .matching(event -> …) // Use some predicate here
 .…

这些行设置了完成标准,最终执行将等待其完成。换句话说,上面的示例将导致执行最终阻塞,直到达到默认超时或发布了符合定义的谓词的 SomeOtherEvent 事件。

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.

执行基于事件的 Scenario 的终端操作名为 ….toArrive…(),它允许(可选地)访问发布的预期事件或原始刺激中定义的 Bean 调用的结果对象。

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.

Triggering the verification
  • 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.

A complete 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:

Expecting a state change
  • Java

  • Kotlin

scenario.publish(new MyApplicationEvent(…))
  .andWaitForStateChange(() -> someBean.someMethod(…)))
  .andVerify(result -> …);
scenario.publish(new MyApplicationEvent(…))
  .andWaitForStateChange(() -> someBean.someMethod(…)))
  .andVerify(result -> …)

传递到 ….andVerify(…) 方法中的 result 是由方法调用返回的值,用于检测状态更改。默认情况下,非 null 值和非空 Optional 将被视为确凿的状态更改。这可以通过使用 ….andWaitForStateChange(…, Predicate) 重载来调整。

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

要自定义某个单个场景的执行,请在 Scenario 的设置链中调用 ….customize(…) 方法:

To customize the execution of an individual scenario, call the ….customize(…) method in the setup chain of the Scenario:

Customizing a 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 -> …)

要全局自定义测试类的所有 Scenario 实例,请实现一个 ScenarioCustomizer 并将其注册为 JUnit 扩展。

To globally customize all Scenario instances of a test class, implement a ScenarioCustomizer and register it as JUnit extension.

Registering a 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 -> …
    }
  }
}