Skip to content

Optimize context caching in the TestContext framework #32289

Open
@seregamorph

Description

@seregamorph

The Spring TestContext Framework (TCF) uses a pretty convenient and flexible approach to create and subsequently reuse the context by aggregate MergedContextConfiguration. However it has a drawback: in a large test suite there can eventually be created too many parallel active contexts, consuming a lot of resources like large thread pools or Testcontainers beans.

Screenshot 2024-02-18 at 12 46 45

There are several good practices to reduce the number of independent configurations like introducing common test super classes and reducing usage of @MockBean annotations. Also we can reduce the overhead of each new context like statically defined/reusable Testcontainers containers.

Unfortunately, these approaches do not work very well for big distributed projects with many teams contributing independently. So eventually OOM and other problems arise.

As a mitigation there can be some urgent fixes like using @DirtiesContext or the spring.test.context.cache.maxSize=1 option as suggested by @snicoll (spring-projects/spring-boot#15654). The suggested approach fixed the problem, but it has a disadvantage as well: the total test execution time increased, due to the larger number of context re-initializations.

I had the same problem while working with a https://miro.com monolith server application, and I've found two more approaches to reduce the number of active contexts.

Smart (Auto) DirtiesContext

For single-threaded test executions, we can know the sequence (list) of tests in the very beginning of the suite. It's easy to calculate the MergedContextConfiguration per each class - and now it's possible to define a custom SmartDirtiesTestExecutionListener with an afterTestClass implementation pretty similar to standard DirtiesContextTestExecutionListener, but there is binary logic: if the current test class is the last test class using a given context configuration, close the context by marking it as dirty.

This trivial approach significantly reduced the number of active contexts and decreased the time of test execution (as fewer resources like CPU were consumed).

The only problem was that on the level of the TCF it's not possible to access the suite, so I originally implemented a custom TestNG listener, and later the JUnit 5 implementation was added.

Test reordering

We can do even better if the test execution sequence is reordered - so we can group test classes that share the same context configuration sequentially, and the number of active spring contexts will never exceed one.

The following chart demonstrates the approach (same color = same MergedContextConfiguration):

reorder-and-smart-dirties-context

It's not possible to reorder tests on the level of the TCF, but it's possible to do so on the level of:

Metrics: fewer contexts and faster

Here is a sample test suite:

Screenshot 2024-02-18 at 12 53 24

On the horizontal axis there is a timeline and on the vertical axis the number of active spring contexts (calculated each 10 sec). As you can see, the Smart DirtiesContext + test reordering (yellow) is always better - it has fewer active contexts, and the total time of test execution is the smallest (because of less CPU consumption + minimal context re-initialization).

The following chart is about number of parallel active Testcontainers docker containers (represented as spring Beans) for another test suite and is even more representative (unfortunately I cannot compare with cache.maxSize=1 approach):
Screenshot 2024-02-18 at 16 32 16

Prototype

I've made a library https://github.com/seregamorph/spring-test-smart-context that implements this approach for JUnit Jupiter, TestNG, and even JUnit 4 via the vintage-engine to demonstrate the approach.

@snicoll and @marcphilipp were so kind to give some initial feedback, and then Stéphane suggested to submit a ticket to continue discussion here.

I understand that the current implementation of the TCF conceptually does not allow this approach as it works on another level, but this can be a possible direction of library evolution (for both spring-framework and junit-platform). As this approach has significant advantages like flexibility and freedom for engineers - they do not need to care too much regarding the optimizations.

Spring team, curious about your opinion. cc @sbrannen

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions