improving spring-boot test efficiency
Problems
- tests are consuming much resources (mostly memory)
- tests are taking too much time
- tests are flaky (e.g. random OOM)
- tests conflicting each other (e.g. fixed ports)
- less motivation to write more tests
- can lead to urgent inefficient decisions
- high costs
- mixed log of old activities in current test
How does spring execute tests?
spring uses a MergedContextConfiguration
to check whether the test configuration is the same or not.
And if it’s the same, it will use the one from the cache.
This MergedContextConfiguration
is calculated statically before running test, based on 10 parameters.
Here are some of the parameters:
@ContextConfiguration(classes = {
DynamoPlatformApplicationsTestConfiguration.class
}) // CLASSES
@ActiveProfiles(TestProfiles.IT_TEST) // ACTIVE PROFILES
@TestPropertySources(properties = {
"com.foo.bar.dynamo-migration.mode=skip"
}) // PROPERTY SOURCES
class DynamoDBPlatformApplicationsRepositoryIT extends AbstractSpringIntegrationTest {
@MockitBean
private final tokenStore tokenStore; // CUSTOMIZERS
}
spring context creation and start are expensive
Each time you see a spring banner, that means a new spring context is created. That’s a clue that your tests are not optimized.
What can it be done to improve the tests?
- limit the number of active contexts
- reduce the number of unique configurations
- reduce the overhead of each new context
- adjust the standard logic of test execution
Optimizations
1. Be careful with standard @DirtiesContext
annotation
It was initially created to close context if bean state was changed.
All spring contexts are closed at the JVM shutdown.
Using @DirtiesContext
in the parent class, will always re-create the spring context.
⇒ slower tests.
One mitigation is to limit the context cache size:
# default size of cache is 32
spring.test.context.cache.maxSize=4
or in pom.xml
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Dspring.test.context.cache.maxSize=1</argLine>
</configuration>
</plugin>
By putting the cache size to 1, spring will re-use some of the context, but some contexts are still re-initialized, as it might depends on the test execution order.
This is not a perfect solution, but still better than global @DirtiesContext
.
2. Introduce common test super-class with context configuration
One parent class that defines the spring configuration for all integration tests. So the spring context will be used as much as possible. Subclasses should not if possible specify any kind of customizations.
In JUnit Jupiter, it can be a meta-annotation.
3. Properly define MockBean
Avoid defining MockBean
/ SpyBean
fields in IT test classes.
Prefer defining in IT super classes and shared Test configurations.
This will be considered for spring test as a customization.
4. Reuse static docker container
- Instead of multiple docker containers, use a static single container, so the tests are using the same container.
- Tests should be designed in a non-conflicting way (accessing same tables).
- Introduce cleanup test listeners (
TRUNCATE
tables).
@TestConfiguration
public class DynamoDBTestConfiguration {
private static DynamoDbTestContainer container;
private static synchronized DynamoDbTestContainer getInstance() {
if (container == null) {
container = new DynamoDbTestContainer();
container.start();
// Testcontainers Ryuk daemon will close all dangling containers, but we can help it
Runtime.getRuntime()
.addShutdownHook(container::close);
}
}
public static final String DEFAULT_DDB_CONTAINER_BEAN_NAME = "DdbContainer";
// We are overriding destroy method, because this bean is a singleton across all contexts
// and we are closing it via runtime shutdown hook after all the tests are done
@Bean(destroyMethod = "", name = DEFAULT_DDB_CONTAINER_BEAN_NAME)
public DynamoDbTestContainer dynamoDBTestContainer() {
return getInstance(); // static singleton
}
}
5. Pre-build docker image with schema (and data)
Instead of running each time the db migration, you can create a docker image containing the database with the applied migrations.
6. Lazy init of database containers
- non-started postgresql docker container is wrapped to special lazy datasource
- on the first access to a datasource, the container is started
- makes sense if app has multiple datasources
Cons: in case of failure, it will be repeatable on test methods (instead of Before
block of init).
7. Use dynamic ports
Use dynamic ports for embedded HTTP server and docker containers.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class FoobarIT {
@LocalServerPort
private int port;
}
This will allow parallelism.
Tip
If you create a server socket, use port 0 (dynamically allocated).
8. Auto-close context on last usage
Close context right after last class per config.
9. Reordered test execution and auto-closing context
Reorder test execution to execute the tests that share the same configuration, so that the same spring context is used, then closed when executing another test suites.
⚠️ spring test cannot control test execution order. The speaker promotes using his library to tackle this issue: https://github.com/seregamorph/spring-test-smart-context.
Bad practices
1. docker container is not a bean
// Avoid
@Bean
DataSource dataSource() {
var container = new PostgreSQLContainer<?>("postgresql:9.6");
return createDataSource("main", container);
}
Such containers are not under spring lifecycle. testcontainers Ryuk will eventually close them, but it can be much later.
// Prefer
@Bean
PostgreSQLContainer<?> postgreSQLContainer() {
return new PostgreSQLContainer<?>("postgresql:9.6");
}
@Bean
DataSource dataSource(PostgreSQLContainer<?> container) {
return createDataSource("main", container);
}
2. ExecutorService
is not shut down
// Avoid
@Service
public class DefaultScheduler {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(16);
public void scheduleNow(Runnable command, long periodSeconds) {
scheduler.scheduleAtFixedRate(command, 0L, periodSeconds, TimeUnit.SECONDS);
}
}
There’s a thread leakage here. Not noticibale on small scale though.
// Prefer
@Service
public class DefaultScheduler {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(16);
public void scheduleNow(Runnable command, long periodSeconds) {
scheduler.scheduleAtFixedRate(command, 0L, periodSeconds, TimeUnit.SECONDS);
}
@PreDestroy
public void shutdown() {
scheduler.close();
}
}