Dieser Artikel ist auch auf Deutsch verfügbar

In many projects and codebases I have seen over the years, excessively long test execution times were an issue. On one hand, we want to achieve an acceptable test coverage, however high that is. On the other hand, we would rather not wait too long for the tests to run. A classic trade-off.

Especially in Spring Boot-based applications, I have noticed that there are often too many tests that require an application context. Simply launching many application contexts alone can result in overly long test execution times. Especially when this also requires starting up or migrating databases.

For this reason, we will be examining here how to write tests in Spring Boot applications and considering in each case how the particular method influences the execution time of our tests.

Unit tests without application context

The first method consists of writing pure unit tests without a Spring application context. The overhead of the application context is eliminated, making the tests very fast. This method is feasible and effective primarily with components for which we want to test pure logic and that are not extended by aspects such as transactions.

Because Spring Beans generally already follow the principles of Inversion of Control and Dependency Injection, it takes little effort to write pure unit tests. We can instantiate the class to be tested via its constructor and inject dependencies as parameters or via setters.

If we would rather not use the real implementation for a dependency in the test, we can replace it with mocks or stubs. This is always useful whenever the real implementation is too complex to instantiate, has undesirable side effects for the test or slows it down, perhaps because it would establish an external connection. Listing 1 shows how such a pure unit test might look for a Spring Bean Greeter.

class GreeterUnitTest {

    GreetingTextRepository greetingTextRepository =
        mock(GreetingTextRepository.class);
    Greeter greeter = new Greeter(greetingTextRepository);

    @Test
    void greet_shouldReturnCorrectGreeting() {
        when(greetingTextRepository.getDefaultGreetingText())
            .thenReturn("Hello, %s!");

        var greeting = greeter.greet(new Name("Michael"));

        assertThat(greeting)
            .isEqualTo("Hello, Michael!");
    }
}
Listing 1: Pure unit test for Greeter

Since these tests are simple and fast, I believe they are always worth considering as the first choice. Because this requires that meaningful testing is possible without an application context, this also influences the production code. For this reason, I avoid implementing more complex logic in controllers, for example. I use these to validate the passed parameters, convert types if necessary, and then call a business logic class. This method, based on the principle of Separation of Concerns, is useful without testing in mind, too. Listing 2 shows such a controller.

@RestController
@RequestMapping("/greet")
public class GreetController {

    private final Greeter greeter;

    public GreetController(Greeter greeter) {
        this.greeter = greeter;
    }

    @GetMapping(params = "name")
    public String greet(@RequestParam String name) {
        if (name.isBlank()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        return greeter.greet(new Name(name));
    }
}
Listing 2: Controller without complex logic

To keep the controller simple, the functionality for removing whitespace before and after the passed name (see Listing 3) is intentionally implemented in the business logic class Greeter and is not part of the controller.

@Test
void greet_shouldStripLeadingAndTrailingWhitespace() {
    when(greetingTextRepository.getDefaultGreetingText())
        .thenReturn("%s");

    var greeting = greeter.greet(new Name(" Michael \t "));

    assertThat(greeting)
        .isEqualTo("Michael");
}
Listing 3: Test of the removal of whitespace at the beginning and end

Even if this kind of pure unit test is my first choice, there are times when the functionality I want to test requires an application context. Let’s have a look at how to write tests in such cases.

Tests with application context

In our application, every call of the greet method should write the value of the passed parameter and the identified greeting to the standard output. This requirement was implemented with an aspect GreeterLoggingAspect.

Because Spring weaves this aspect around our class, we need a test with application context to test this functionality. Listing 4 shows how to write such a test with the tools from the Spring Framework.

@SpringJUnitConfig
@ExtendWith(OutputCaptureExtension.class)
class GreeterSpringTest {

    @Autowired
    GreetingTextRepository greetingTextRepository;

    @Autowired
    Greeter greeter;

    @Test
    void greet_shouldBeWrappedByLoggingAspect(CapturedOutput output) {
        when(greetingTextRepository.getDefaultGreetingText())
            .thenReturn("Hello, %s!");

        greeter.greet(new Name("Michael"));

        assertThat(output.getAll())
            .endsWith("""
                Method greet called with [Name[value=Michael]]
                Method greet returned 'Hello, Michael!'
                """);
    }

    @TestConfiguration
    @EnableAspectJAutoProxy
    @Import({Greeter.class, GreeterLoggingAspect.class})
    static class AopTestConfiguration {

        @Bean
        public GreetingTextRepository greetingTextRepositoryMock() {
            return mock(GreetingTextRepository.class);
        }
    }
}
Listing 4: Test for an aspect using Spring testing

A single annotation, @SpringJunitConfig, is apparently sufficient to generate an application context and have it available in the test. This is possible because the annotation itself features two additional annotations. The annotation @ExtendWith(SpringExtension.class) ensures that a Spring-specific JUnit 5 extension is activated. This gives it access to the entire lifecycle of the test. In consequence, the extension can do things before and after the test execution and also supply fields or method parameters that can be used in the tests. The second annotation @ContextConfiguration allows the specification of which configurations, beans and other components should be brought into the application context of the test.

The following then happens during the execution of a test annotated in this way. The test framework, in our case JUnit 5, locates and starts the test. The activated spring extension is then called at the appropriate points in the lifecycle of the test. This extension then instantiates a TestContextManager for each test class. In turn, this is responsible for generating a TestContext and updating it during the test execution. It also signals to TestExecutionListeners at certain points in the test, such as before and after each test method. These listeners are independent of the specific test framework and handle specific tasks, such as injecting dependencies from the application context or rolling back a transaction.

To generate a TestContext, the TestContextManager makes use of a TestContextBootstrapper. This is responsible for finding and registering the required TestExecutionListeners. It also generates the application context for the test with the help of a ContextLoader. Because the generation of an application context is expensive, this is also cached so that it can be reused for other tests that require an identical application context. The documentation explains how the cache key is calculated and how to influence the cache from the outside.

Because the @ContextConfiguration was not configured with parameters within the @SpringJunitConfig and because our test contains no further annotation, our test runs with the standard configuration. This is where our static inner class is found and – because it is indirectly annotated with @Configuration via @TestConfiguration – brought into the application context. Because this activates the Spring support for aspects via @EnableAspectJAutoProxy and specifies the beans relevant to us by @Import and @Bean, it is possible to execute the test. The test method in Listing 5 shows that these beans were truly loaded via Spring and that the autoconfiguration for aspects present in Spring Boot was not utilized for this.

@Test
void applicationContext_shouldContainGreeterAndRepositoryAndAspect ButNoAutoConfiguration(
        ApplicationContext applicationContext) {
    var beanNames = applicationContext.getBeanDefinitionNames();

    assertThat(beanNames)
        .contains(
            "de.mvitz.spring.test.slices.Greeter",
            "greetingTextRepositoryMock",
            "de.mvitz.spring.test.slices.GreeterLoggingAspect",
            "org.springframework.aop.config.internalAutoProxyCreator")
        .doesNotContain(
            "org.springframework....AopAutoConfiguration");
}
Listing 5: Test for beans present in the application context

Today, however, most Spring applications are based on Spring Boot and rely on the conventions and autoconfigurations present there. That’s why expanded support for tests is available there, which we would now like to examine.

Spring Boot tests

The first annotation usually encountered when searching for tests with Spring Boot is @SpringBootTest, so let’s begin with that approach. In principle, this replaces the previous @SpringJunitConfig. A different Spring Boot-specific TestContextBootstrapper is used here, however. This bootstrapper knows the Spring Boot conventions and utilizes the concept of autoconfiguration, which is why a test annotated in this way (see Listing 6) loads the entire application context just as would happen when the application is launched, with only a few exceptions. In this case, that is precisely the reason this test fails. Because the entire application context is loaded, the pool for database connections is also initialized, and an attempt is made to establish a connection to a database that is not running.

@SpringBootTest
class GreeterSpringBootTest {

    @Autowired
    Greeter greeter;

    @MockBean
    GreetingTextRepository greetingTextRepository;

    @Test
    void greet_shouldReturnGreetingWithTextFromDatabase() {
        when(greetingTextRepository.getDefaultGreetingText())
            .thenReturn("Welcome %s.")

        var greeting = greeter.greet(new Name("Michael"));

        assertThat(greeting)
            .isEqualTo("Welcome Michael.");
    }
}
Listing 6: @SpringBootTest for Greeter

We circumvent this problem by adding another annotation, specifically @AutoConfigureTestDatabase. This ensures that the configured connection is not used upon generation of the application context, and that this is replaced by a connection to a database running in memory instead. Consequently, we can also dispense with mocking the GreetingTextRepository. In Listing 7, we then have an integration test that utilizes the correct repository and therefore executes an actual query to the database.

@SpringBootTest
@AutoConfigureTestDatabase
@ExtendWith(OutputCaptureExtension.class)
class GreeterSpringBootTest {

    @Autowired
    Greeter greeter;

    @Test
    void greet_shouldReturnGreetingWithTextFromDatabase() {
        var greeting = greeter.greet(new Name("Michael"));

        assertThat(greeting)
            .isEqualTo("Hallo Michael.");
    }

    @Test
    void greet_shouldBeWrappedByLoggingAspect(CapturedOutput output) {
        greeter.greet(new Name("Michael"));

        assertThat(output.getAll())
            .endsWith("""
                Method greet called with [Name[value=Michael]]
                Method greet returned 'Hallo Michael.'
                """);
    }

    @Test
    void applicationContext_shouldContainGreeterAndRepositoryAndAspectAndAutoConfiguration(
            ApplicationContext applicationContext) {
        var beanNames = applicationContext.getBeanDefinitionNames();

        assertThat(beanNames)
            .contains(
                "greetController",
                "greeter",
                "greetingTextRepository",
                "greeterLoggingAspect",
                "org.springframework....AopAutoConfiguration");
    }
}
Listing 7: @SpringBootTest integration test for Greeter

The test also proves that components are loaded that we don’t even need in the test at all (in this case the controller from Listing 2). Especially in larger real-life applications with many components and tests, this all adds up and increases the execution time of the entire test suite. The initialization of databases, including the performance of migrations, takes up a particularly large portion of this time.

To learn how to write fast tests with application context despite this, we will now have a look at the test slice functionality in Spring Boot.

Spring Boot test slices

We now want to write a test for the controller in Listing 2. Because, as already stated, this simply does validation and a type conversion and then hands off to the Greeter, we don’t need a complete integration test here. It is enough for us to mock the Greeter and to verify that the correct value is passed.

Theoretically, we could also accomplish this with a pure unit test. In that case, we would instantiate the controller itself with the constructor and call the method. In doing so, however, we would no longer be testing the aspects added by Spring Web MVC, such as the request routing or the conversion from and to JSON.

This is why Spring Boot offers us a ready-made test slice with @WebMvcTest that only brings into the application context those components which are actually important for our web layer. This includes controllers, filters, and advices. The test shown in Listing 8 gets by with a considerably smaller application context, and is also faster than an equivalent test with @SpringBootTest.

@WebMvcTest(GreetController.class)
class GreetControllerTest {

    @MockBean(name = "greeterMock")
    Greeter greeter;

    @Autowired
    MockMvc mockMvc;

    @Test
    void greet_shouldReturnBadRequest_whenNameIsMissing()
            throws Exception {
        mockMvc.perform(get("/greet"))
            .andExpect(status().isBadRequest());
    }

    @Test
    void greet_shouldReturnBadRequest_whenNameIsBlank()
            throws Exception {
        mockMvc.perform(get("/greet")
                .queryParam("name", " \t \t"))
            .andExpect(status().isBadRequest());
    }

    @Test
    void greet_shouldReturnGreeting_whenNotBlankNameIsGiven()
            throws Exception {
        when(greeter.greet(new Name("Mich \t ael ")))
            .thenReturn("Hallo!");

        mockMvc.perform(get("/greet")
                .queryParam("name", " Mich \t ael "))
            .andExpectAll(
                status().isOk(),
                content().string(is(equalTo("Hallo!"))));
    }

    @Test
    void applicationContext_shouldContainControllerAndGreeterButNotRepositoryOrAspect(
            ApplicationContext applicationContext) {
        var beanNames = applicationContext.getBeanDefinitionNames();

        assertThat(beanNames)
            .contains(
                "greetController",
                "greeterMock")
            .doesNotContain(
                "greetingTextRepository",
                "greeeterLoggingAspect");
    }
}
Listing 8: WebMvcTest for our controller

For the GreetingTextRepository based on the JdbcTemplate, there is also a ready-made test slice in the form of @JdbcTest (see Listing 9). By default, this provides a database in memory, performs the required database migrations and supports transactions. This makes it quick work to write the test for our repository (see Listing 10).

@Repository
public class GreetingTextRepository {

    private final JdbcTemplate jdbc;

    public GreetingTextRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public String getDefaultGreetingText() {
        return jdbc.queryForObject(
            "SELECT text FROM greetings WHERE \"default\"", String.class);
    }
}
Listing 9: GreetingTextRepository
@JdbcTest
@Import(GreetingTextRepository.class)
class GreetingTextRepositoryTest {

    @Autowired
    GreetingTextRepository greetingTextRepository;

    @Test
    void getDefaultGreeting_shouldReturnGreetingTextFromDatabase() {
        var greetingText = greetingTextRepository
            .getDefaultGreetingText();

        assertThat(greetingText)
            .isEqualTo("Hallo %s.");
    }

    @Test
    void applicationContext_shouldContainEmbeddedDataSource(
            ApplicationContext applicationContext) {
        var dataSource = applicationContext.getBean(DataSource.class);

        assertThat(dataSource)
            .isInstanceOf(EmbeddedDatabase.class);
    }
}
Listing 10: JdbcTest for our repository

In addition to these two, Spring Boot has still other test slices to offer. You can find a complete overview of these in the documentation, which explains for each slice which components are included by default and which autoconfigurations are loaded. If no pre-made test slice exists to suit your needs, it is also possible to write your own. Let’s see how that goes using our repository.

Creating a custom test slice

As stated above, the previous test for the GreetingTextRepository tests this in connection with a database in memory and not in combination with a proper PostgreSQL database. For that, we need to write another test (see Listing 11).

@PostgresRepositoryTest(GreetingTextRepository.class)
class GreetingTextRepositoryPostgresTest {

    @Autowired
    GreetingTextRepository greetingTextRepository;

    @Test
    void getDefaultGreeting_shouldReturnGreetingTextFromDatabase() {
        var greetingText = greetingTextRepository
            .getDefaultGreetingText();

        assertThat(greetingText)
            .isEqualTo("Hallo %s.");
    }

    @Test
    void applicationContext_shouldContainTestContainersDataSource(
            ApplicationContext applicationContext) {
        var dataSource = applicationContext.getBean(DataSource.class);

        assertThat(dataSource)
            .isInstanceOf(HikariDataSource.class)
            .asInstanceOf(type(HikariDataSource.class))
            .extracting(HikariConfig::getJdbcUrl, STRING)
            .startsWith("jdbc:postgresql://localhost:")
            .endsWith("/test?loggerLevel=OFF");
    }
}
Listing 11: Test for repository with a proper PostgreSQL database

For this purpose, we defined a custom annotation, @PostgresRepositoryTest (see Listing 12). This annotation is in turn annotated with many other annotations, which ensure that everything functions in combination. We already encountered @ExtendWith(SpringExtension. class) before. This makes sure that the JUnit 5 Spring extension is activated.

@Target(TYPE)
@Retention(RUNTIME)
@Documented
@Inherited
@ExtendWith(SpringExtension.class)
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@TypeExcludeFilters(PostgresRepositoryTypeExcludeFilter.class)
@ImportAutoConfiguration
@OverrideAutoConfiguration(enabled = false)
@ContextConfiguration(initializers = PostgresRepositoryTestInitializer.class)
@Transactional
public @interface PostgresRepositoryTest {

    @AliasFor("repositories")
    Class<?>[] value() default {};

    @AliasFor("value")
    Class<?>[] repositories() default {};
}
Listing 12: @PostgresRepositoryTest

With @BootstrapWith(SpringBootTestContextBootstrapper.class), we replace the default TestContextBootstrapper with a Spring Boot-specific one. Among other things, this uses the filter configured below with @TypeExcludeFilters(PostgresRepositoryTypeExcludeFilter.class). This filter (see Listing 13) ensures that all components annotated with @Repository are included in the application context by default unless the repositories parameter of our annotation has been set, in which case only the components specified there are included.

final class PostgresRepositoryTypeExcludeFilter extends
        StandardAnnotationCustomizableTypeExcludeFilter<PostgresRepositoryTest> {

    private static final Class<?>[] NO_REPOSITORIES = {};
    private static final Set<Class<?>> DEFAULT_INCLUDES =
        Collections.emptySet();
    private static final Set<Class<?>> DEFAULT_INCLUDES_AND_REPOSITORY =
        Set.of(Repository.class);

    private final Class<?>[] repositories;

    PostgresRepositoryTypeExcludeFilter(Class<?> testClass) {
        super(testClass);
        this.repositories = getAnnotation()
            .getValue("repositories", Class[].class)
            .orElse(NO_REPOSITORIES);
    }

    @Override
    protected boolean isUseDefaultFilters() {
        return true;
    }

    @Override
    protected Set<Class<?>> getDefaultIncludes() {
        if (ObjectUtils.isEmpty(this.repositories)) {
            return DEFAULT_INCLUDES_AND_REPOSITORY;
        }
        return DEFAULT_INCLUDES;
    }

    @Override
    protected Set<Class<?>> getComponentIncludes() {
        return Set.of(this.repositories);
    }
}
Listing 13: PosgresRepositoryTypeExcludeFilter

With @ImportAutoConfiguration and @OverrideAutoConfiguration(enabled = false), we ensure that the autoconfigurations defined in the file META-INF/spring/de.mvitz.spring.test.slices.PostgresRepositoryTests.imports are loaded (see Listing 14). Finally, we use @ContextConfiguration to add our own ApplicationContextInitializer to the application context (see Listing 15) and ensure with @Transactional that every test method automatically generates a transaction that is rolled back at the end.

# RepositoryTest auto-configuration imports
org.springframework....flyway.FlywayAutoConfiguration
org.springframework....jdbc.DataSourceAutoConfiguration
org.springframework....jdbc.DataSourceTransactionManagerAutoConfiguration
org.springframework....jdbc.JdbcTemplateAutoConfiguration
org.springframework....transaction.TransactionAutoConfiguration
Listing 14: Autoconfiguration imports
final class PostgresRepositoryTestInitializer implements
        ApplicationContextInitializer<ConfigurableApplicationContext> {

    @Override
    public void initialize(ConfigurableApplicationContext context) {
        final var container = new PostgreSQLContainer<>("postgres:latest")
            .withUsername("test")
            .withPassword("test");

        context.addApplicationListener(
            (ApplicationListener<ContextClosedEvent>) event ->
                container.stop());

        container.start();

        TestPropertyValues.of(
                "spring.datasource.url=" + container.getJdbcUrl(),
                "spring.datasource.username=" + container.getUsername(),
                "spring.datasource.password=" + container.getPassword())
            .applyTo(context.getEnvironment());
    }
}
Listing 15: PostgresRepositoryTestInitializer

The actual logic is located in our initializer. This uses Testcontainers to start a container with a PostgreSQL database and ensures that this is stopped again as well. In addition, it sets the Spring Boot-relevant properties for the database connection. Because the initializer is executed at the start of the generation of an application context, all of that takes place before creation of the components that read these properties. For this specific case of a repository test against a Testcontainer database, there are definitely simpler solutions than a custom test slice. This slice should therefore be seen more as a (hopefully) easy-to-follow example than as a missing slice.

Conclusion

In this article, we considered a number of options for writing tests in applications based on Spring Boot. We saw that – thanks to the principles of Inversion of Control and Dependency Injection – it is both easy and useful to write pure unit tests, meaning without a Spring application context.

If we do need an application context, however, the Spring Framework provides tools for generating one for tests. Spring Boot further builds on this to offer its own abstractions that make the entire process (almost too) convenient.

In addition to @SpringBootTest, we learned about the concept of test slices for testing only a specific layer in isolation within our application. Finally, we also learned how to create our own test slice if none of the slices on offer meet our needs.