Article

Getting started with Spring tests

Improve Spring Boot integration tests coverage, reliability and performances using opinionated practices and tips.

Spring Test JUnit Spring Boot
Intermediate
Florian Beaufumé
Florian Beaufumé
Published 9 Apr 2024 - 5 min read
Getting started with Spring tests

Table of contents

Introduction

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:

  1. Getting started with Spring tests (this article) provides best practices and tips to get started with Spring tests.
  2. Switching between H2 and Testcontainers in Spring tests describes how to easily switch between H2 and Testcontainers in Spring tests.
  3. Security annotations in Spring tests focuses on security annotations used in Spring tests.

A GitHub sample repository link is provided at the end of this article.

A sample test class

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.

Reusing Spring application contexts

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:

  • Using different definitions of @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.
  • Using @SpringBootTest, @WebMvcTest and @DataJpaTest in different test classes will result in three application contexts.
  • Using @AutoConfigureMockMvc in a test class and not using it in another test class will result in two application contexts.
  • Different @ActiveProfiles configurations. For example using @ActiveProfiles("foo") and @ActiveProfiles("bar") in different test classes will result in two application contexts.
  • Using different @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.
  • Using @DirtiesContext.
  • And so on.

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.

Tests implementation strategy

Here is my global strategy when implementing tests:

  • First, for complicated or sensitive methods, I write dedicated unit tests.
  • Then, I write integration tests that execute all application layers, since they have a high coverage/effort ratio. For example ItemControllerIT executes all application components from the controller to the database.
  • Finally, I fill the gaps with additional integration or unit tests.

As a consequence of the previous sections, here is how I usually design my integration tests:

  • Use the same set of class annotation (I mean the ones that impact application context caching).
  • Use no test slices. Instead, integration tests execute all applicable application layers.
  • Do not use @DirtiesContext. Instead, I use @Transactional to rollback the transactional modifications and manual coding to revert non transactional modifications.
  • Do not use @MockBean. Instead, I write additional integration tests (for example for business services) or unit tests for various component types.
  • Inject MockMvc instead of manually creating it.
  • Use @BeforeAll and @AfterAll methods to initialize and cleanup the test data.

Web test types

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.

Using a base class

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.

Conclusion

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é