To write unit tests for a Spring Boot application, we usually do not need Spring. Using JUnit, Mockito and other libraries is enough for clean and efficient test code. But integration tests may span multiple application components, call REST endpoints of the application, use security constraints or require a database. That's where we can benefit from using Spring in the test code too.
But there are many ways to use Spring in integration tests, many choices to make, and several performance pitfalls. In this article, I will describe some of the practices I use to write Spring Boot integration tests with increased coverage and reliability, simplified code and improved performances.
Note that this article is part of a test oriented series:
A GitHub sample repository link is provided at the end of this article.
Let's get started with my default structure for an integration test class:
@SpringBootTest // 1
@AutoConfigureMockMvc // 2
@ActiveProfiles("test") // 3
@Transactional // 4
public class ItemControllerTest { // 5
@Autowired
private MockMvc mockMvc; // 6
@BeforeAll // 7
static void beforeAll(@Autowired ItemRepository itemRepository) {
// Initialize the database content
itemRepository.saveAll(List.of(new Item("item1", 10), new Item("item2", 20)));
}
@AfterAll // 8
static void afterAll(@Autowired ItemRepository itemRepository) {
// Cleanup the database
itemRepository.deleteAll();
}
@Test
public void getItems() throws Exception { // 9
mockMvc.perform(get("/items"))
.andExpect(status().is(200))
.andExpect(jsonPath("$", hasSize(2))) // 10
.andExpect(jsonPath("$[0].name", is("item1")))
.andExpect(jsonPath("$[0].price", is(10)))
.andExpect(jsonPath("$[1].name", is("item2")))
.andExpect(jsonPath("$[1].price", is(20)));
}
// Other test methods
}
Note 1, @SpringBootTest
is enough to be able to use Spring in the test class.
Note 2, @AutoConfigureMockMvc
makes a pre-configured MockMvc
instance injectable,
in order to perform API tests. Simpler than manually build the instance.
Note 3, @ActiveProfiles("test")
enables the test
Spring profile.
This is convenient when specific test configuration is needed. You can ignore this if you are satisfied with the default
Spring configuration
Note 4, @Transactional
is used to define the transactional behavior of the tests methods. When used
on a test class it acts a bit differently than when used on a business component. Here, this makes
each test method executed in a dedicated transaction, and makes that transaction being rolled back at the end of
the test method. This is useful when the test methods modify the database, to make sure that each test method leaves
a clean database for the next method, in order to increase the tests reliability. This annotation could also be used
at the test methods level.
Note 5, the Test
suffix is used to run the test with the Maven Surefire plugin using
mvn test
(or mvn package
, etc). If you prefer to run them with mvn verify
from
the Maven Failsafe plugin, change the suffix to IT
.
Note 6, MockMvc
can be injected using standard @Autowired
, then used in test methods.
Note 7, @BeforeAll
is used to execute some code before all the test methods. This is useful to insert
in the database some data that are used by all the test methods. Note that beans can be injected into the method using
the @Autowired
annotation. Initializing the database in a @BeforeAll
method rather than
in @BeforeEach
method can result in better performances since the former is executed only once per test
class while the later is executed once per test method of the test class. Of course, if we need specific data in the
database for a given test method, we can also insert or update them directly from the test method. These specific data
will be reverted anyway thanks to the @Transactional
annotation.
Note 8, @AfterAll
is used to execute some code after all the test methods. We usually undo everything
that was done in the @BeforeAll
method.
Note 9, this is a sample test method. It is a regular JUnit test method, and can use the injected MockMvc
instance and other injected beans, if any.
Note 10, this code sample shows path based assertions. You can also use strongly typed DTO based assertions, for example with a Java record. The linked sample repository shows both approaches.
Performance is usually not a major criteria for tests. But I think that reducing the tests execution duration by several (or even multiple) folds can improve productivity: faster tests means that developers can run them more frequently hence pinpoint and fix issues earlier.
Using per class, rather than per method, data initialization as seen earlier is one way to improve performances. But there is a much better performance lever to pull: reduce the Spring initialization duration by reusing the Spring application contexts instead of creating new ones. An additional benefit can also be a much lower peak memory usage.
If you have a look at the test logs, you may see Spring Boot initialization logs for some test classes but not for
some others. These logs mean that a new Spring application context is created. This is usually slow
(seconds to tens of seconds depending on the application complexity) and often much slower than the test methods
execution. This also consumes a significant amount of memory. To mitigate this problem, you could use multiple small
fine-tuned Spring application contexts in your test classes. Spring offers several solutions to do so:
@ContextConfiguration
, @WebMvcTest
(i.e. web slicing), @DataJpaTest
(i.e. data slicing), etc.
This may be useful for your application. I usually do the opposite: one single big Spring application context.
So far, it gave me much better performances and simpler test code.
To achieve this, we must first understand when new Spring application contexts are created by tests. Spring tries his best to reuse the application contexts. They are cached during the execution of the test classes. When Spring decides that it can reuse a cached application context, the test class startup is much faster. But in several cases, Spring cannot reuse a cached application context and creates a new one, such as:
@SpringBootTest(...)
. For example @SpringBootTest
in a test
class and @SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT)
(more on that later)
in another test class will result in two application contexts.@SpringBootTest
, @WebMvcTest
and @DataJpaTest
in different test classes
will result in three application contexts.@AutoConfigureMockMvc
in a test class and not using it in another test class will result in
two application contexts.@ActiveProfiles
configurations. For example using @ActiveProfiles("foo")
and
@ActiveProfiles("bar")
in different test classes will result in two application contexts.@MockBean
configurations in test classes. For example using @MockBean FooService
in a test class and @MockBean BarService
in another test class will result in two application contexts.@DirtiesContext
.Note that @Transactional
on a test class has no impact on application context caching, we can choose
to use it or not solely based on how we want to manage the data integrity.
Here is my global strategy when implementing tests:
ItemControllerIT
executes all application components from the controller to the database.As a consequence of the previous sections, here is how I usually design my integration tests:
@DirtiesContext
. Instead, I use @Transactional
to rollback the transactional
modifications and manual coding to revert non transactional modifications.@MockBean
. Instead, I write additional integration tests (for example for business services)
or unit tests for various component types.MockMvc
instead of manually creating it.@BeforeAll
and @AfterAll
methods to initialize and cleanup the test data.MockMvc
is very convenient to test our web endpoints. It does not actually perform a real HTTP request,
but directly executes the controller method (and other components such as web filters) using the same thread and the
same transaction. This approach is sometimes called "server web test", meaning that the test code is executed on the
server side. As a consequence, we can rely on the @Transactional
annotation on the test class to revert
database writes performed by both the test method and the tested business code.
But sometimes this is not what we want. We may, for example, need the business transaction to commit, or a real HTTP
request to be performed for some side effect to happen. In that case we can use
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
to open an actual
HTTP port then use TestRestTemplate or REST Assured to call the endpoints using a real HTTP request.
I usually prefer REST Assured. This approach is sometimes called "client web test", meaning that the test code is
executed on the client side, using a different thread and a different transaction than the one used by the business
code.
Here is a code sample using REST Assured:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc // To prevent application context proliferation
@ActiveProfiles("test")
@Transactional
public class ItemControllerClientTest {
@LocalServerPort // Get the port opened by Spring Test
private int port;
@BeforeAll
static void beforeAll(@Autowired ItemRepository itemRepository) {
itemRepository.saveAll(List.of(new Item("item1", 10), new Item("item2", 20)));
}
@AfterAll
static void afterAll(@Autowired ItemRepository itemRepository) {
itemRepository.deleteAll();
}
@BeforeEach
public void beforeEach() {
// Give to REST Assured the port opened by Spring Test
RestAssured.port = port;
}
@Test
public void getItems() {
RestAssured.given().get("/items").then()
.statusCode(200)
.body("size()", is(2))
.body("[0].name", is("item1"))
.body("[0].price", is(10))
.body("[1].name", is("item2"))
.body("[1].price", is(20));
}
// Other test methods
}
Note that @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
supports both
client and server web test approaches, you still can inject a MockMvc
instance. That's why I use it instead
of @SpringBootTest
to reduce the amount of Spring application contexts when I need to execute both
approaches. In that case the test class annotations become:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
Once gain, feel free to use @Transactional
or not to manage the data integrity.
As you may have already guessed, I do not actually duplicate the annotations on the integration test classes.
Instead, I group them in a base integration test class and make the test classes extends that base class.
This prevents code duplication of the class annotations as well as injected beans such as
MockMvc
, ObjectMapper
or ResourceLoader
instances.
The base class is also a convenient place to add utility methods, for example to simplify the usage of MockMvc and
REST Assured.
A sample base class:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@ExtendWith(MyExtension.class)
public abstract class BaseIntegrationTest {
@Autowired
protected MockMvc mockMvc;
// Inject other beans as needed (ObjectMapper, ResourceLoader, etc)
// Various utility methods for MockMvc, REST Assured, etc
}
This base class is also a good fit to declare our shared JUnit 5 extensions. These extensions are similar to JUnit 4 rules. They are classes that can be used to factorize some test code.
The ItemControllerTest
is now leaner:
@Transactional
public class ItemControllerTest extends BaseIntegrationTest {
// @BeforeAll, @AfterAll and test methods
}
Note that I chose to keep @Transactional
at the concrete class level, since it is test class specific
and does not impact the Spring application context caching.
In this article I explained several opinionated practices that I use to improve integration tests coverage, reliability, maintainability and performances. I hope that you found them useful to define your own best practices.
A sample project is available in GitHub showing several of these practices, see spring-tests.
© 2007-2024 Florian Beaufumé