A little more on Spring tests - good practices
Mateusz Chrzonstowski,
Damian Kaczmarczyk
December 11, 2023
Within Paramount+, we recently improved our integration tests and their execution time, especially on CI.
This article provides hints on how to define good level of tests, how to make tests more readable, and how to use some underlying mechanisms from Spring for easier maintenance.
In the second article, we’ll show what optimizations we did for tests and what you might check in your app.
Challenging the testing pyramid
You probably have heard of a testing pyramid, visualizing unit tests as the base of the software, with integration and E2E tests applied on lower and lower scales.
As in architecture we don’t follow pyramidal shapes anymore, it’s not a surprise the software testing pyramid also got challenged. One of the articles worth providing here is Testing of Microservices by Spotify. There, they propose the following “honeycomb” (a.k.a. “diamond”) shape, where the majority of tests are the integration ones.
The above approach makes sense, but there is also another view on this, where people question rather the “unit” part
from the name “unit tests” and define the unit more like a component, e.g.
from C4 architecture model.
A component can correspond with Java’s package
itself and to see how to divide the app into mid-sized building blocks
and how to test them not to fix yourself on implementation details, but on behaviors, we recommend checking the
following
talks from Jakub Nabrdalik:
- Keep IT clean: mid-sized building blocks and hexagonal architecture
- Improving your Test Driven Development in 45 minutes
The need of integration tests
As we already know it’s worth thinking about bigger units and integration tests themselves, let’s define where the border between tests is. Spring doc on integration testing states:
[Integration Testing] lets you test the correct wiring of your Spring IoC container contexts
In other words, each time you start Spring context, you have an integration test as Spring is responsible for a proper “integration” of your dependencies, even if they don’t touch IO.
With such a definition, we can immediately see integration tests can help us to spot the issues un-spottable even with good component tests, e.g. logic hidden in
@HystrixCommand
@Transactional
@Cacheable
@ConditionalOn...
- Filters
- Interceptors
@PreAuthorize
Basically, with all Aspect-Oriented Programming, called from time to time “Spring magic”, requiring Spring context and beans management.
Here, it’s worth mentioning, though, that Spring itself provides many tools for testing and the logic coming
from @ConditionalOn...
can be tested in an unit-like manner (without a real Spring context), just by using
Spring’s ApplicationContextRunner
.
Batteries included - what tools are already there
ApplicationContextRunner
is a nice thing, but what else comes when including spring-boot-starter-test
?
spring-boot-test
,spring-test
- helpers from Spring, e.g. mentionedApplicationContextRunner
,SpringExtension
JUnit
(Platform + Jupiter) - tests runner and frameworkMockito
- a tool for mocking. BTW it’s worth checkingMockitoExtension
andBDDMockito
which are already thereAssertJ
,Hamcrest
- fluent assertions, so you don’t need to guess anymore order of parameters if it’sassertEquals(expected, actual)
orassertEquals(actual, expected)
😉 it’ll be justassertThat(actual).isEqualTo(expected)
or eventhen(actual).isEqualTo(expected)
withBDDAssertions
which are already there- Things helping to test JSON, XML and others (worth to mention
BasicJsonTester
for JSON andOutputCaptureExtension
for checking log messages) Awaitility
for active waiting in your tests
Annotations, meta-annotations and their quirks
Both Spring and JUnit interpret annotation put on annotation as if it was put directly on the class. This means whenever you see
@SpringBootTest
@ExtendWith(SpringExtension.class)
class IntegrationTest {
/* ... */
}
you can safely remove @ExtendWith(SpringExtension.class)
as @SpringBootTest
itself already adds it (and more):
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
/* ... */
}
It gets even more interesting when you check other annotations, e.g.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
/* ... */
}
JavaDoc hints there,
it’s enough to add @AutoConfigureTestDatabase
and a DB dependency like H2 to make tests establishing H2 connection
with proper Spring properties. No need to set up H2 on your own for tests.
And @Transactional
in integration tests is even more powerful. JavaDoc of DataJpaTest
says tests marked like that
are transactional and roll back at the end of each test
It means we can get a good level of isolation out of the box - each test runs within its own transaction and doesn’t
commit anything. How often did you see some DbCleaner
used to remove everything from DB at the end of the test?
Using @Transactional
could simplify this.
On the other hand, “with great power comes great responsibility”, and JPA with transactions might lead to some problems, described well in the article Spring pitfalls: transactional tests considered harmful. Even though it’s from 2011, most things are still relevant (perhaps just Scala part didn’t age well 😉). But we can ask here if JPA is really needed. Maybe Spring Data JDBC alone or other light solutions are enough?
Lastly, you might often see RestAssured
or other additional dependencies for testing your app through HTTP APIs.
Instead, you might consider simple @AutoConfigureMockMvc
and MockMvc
introduced directly by Spring with a fluent
API, not worse than in other solutions (but already well integrated and tested by Spring team).
Sharing the setup
Coming up with your own set of annotations you want to use, and knowing both Spring and JUnit understand annotations on annotations, consider introducing your custom meta-annotation, e.g.
@Tag("integration")
@Transactional
@AutoConfigureMockMvc
@SpringBootTest
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface IntegrationTest { }
Then, you can easily reuse the setup across all the test classes:
@IntegrationTest
class LimitingControllerIntTest {
/* ... */
}
Having a single source of setup is helpful as it’s easy to start many different Spring contexts in integration tests and each comes with its startup and memory use costs. A single point of configuration puts a focus on the common part first, and people might rather work on it, extending it further, instead of introducing a different set of annotations on different test classes.
We’ll cover more on Spring contexts and context caching in the next article.
BTW, with base classes or interfaces you might achieve same benefits as with meta-annotations, but it’s a different discussion, touching composition over inheritance topic among others.
Tests as addons and enablers
Single point of setup and good choice of helpers is desired, but one might still create configurations in production code like
@Configuration
class DbConfigurationBeans {
@Bean
@Profile("!test")
CarouselRepository carouselRepository(DbClient client) {
return new SpannerCarouselRepository(client);
}
// ...
}
This works, but might be considered a code smell. Not only production code is affected by tests (while tests should work
as enabler for production code changes, not limiting your production code freedom), but also such things don’t scale
well. It’s a matter of time you’ll need another profile, e.g. for “contract tests” where some things should be taken as
in prod
, some as in current test
profile. Then, another profile and another, and soon you might end up with a bunch
of configuring annotations or complex expressions used inside @Profile
or @ConditionalOn...
annotations, which is
actually “stringly” typed programming, where errors get discovered at runtime, not at compile-time. Alternatively, you
might end up with dozens of profiles and corresponding properties/YAML files, which is also hard to maintain.
Creating and combining profiles is a good topic for a dedicated article. Still, you might consider
“feature-oriented” profiles rather than mixing infrastructure, environment, app mode, and other things within the same
flat file. Perhaps localstack
and aws
(with URLs and access details) are good profiles as add-ons to kinesis
(with
topics), and prod
or dev
can just include localstack
+kinesis
or aws
+kinesis
. Many things can also be
simplified by a good naming convention and environment variables, e.g.
base.solr.url: http://solr-cache/${ENVIRONMENT:local}/blog
And instead of a bunch of @Profile
annotations, consider dedicated testing properties modifying the production setup.
E.g. don’t configure conditionally in production code, but turn something off when testing:
// just in tests
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "app.metrics.type", havingValue = "in-mem") // unknown property outside tests
@EnableAutoConfiguration(exclude = PrometheusMetricsExportAutoConfiguration.class) // off!
class PrometheusTestConfiguration {
@Bean
CollectorRegistry collectorRegistry() {
return new CollectorRegistry();
}
}
Sometimes, you might also override your production beans in tests:
@Bean
@Primary
SpannerOptions spannerEmulatorOptions(
SpannerOptions original,
@Value("${spanner.emulator-host}") String spannerHost) {
return original.toBuilder()
.setNumChannels(1)
.setSessionPoolOption(SessionPoolOptions.newBuilder().setMaxSessions(1).build())
.setEmulatorHost(spannerHost).build();
}
Last words
Good test setup and hygiene are essential, but tests might get out of hand even with a single setup source and start more Spring contexts than needed. In the following article, we’ll cover our journey to put tests back on track and improve their speed.