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
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.
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.
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
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.
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
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.
@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
@TestConfiguration – brought into the application context. Because this activates the Spring support for aspects via
@EnableAspectJAutoProxy and specifies the beans relevant to us by
@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.
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.
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.
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
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).
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).
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.
@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.
@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.
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.