Testing Spring Boot Conditionals: The Sane Way
Testing Spring Boot conditionals can drive you crazy.
Join the DZone community and get the full member experience.
Join For FreeIf you are more or less experienced Spring Boot user, it is very possible that, at some point, you ran into a situation where the particular beans or configurations had to be injected conditionally. While the mechanics of it is well understood, sometimes, testing such conditions (and their combinations) can get messy. In this post, we are going to talk about some possible (arguably, sane) ways to approach that.
Since Spring Boot 1.5.x is still widely used (nonetheless, it is racing towards the EOL this August), we would include it along with Spring Boot 2.1.x, both with JUnit 4.x and JUnit 5.x. The techniques we are about to cover are equally applicable to the regular configuration classes as well as auto-configurations classes.
The example we will be playing with is related to our homemade logging. Let us assume our Spring Boot application requires some bean for a dedicated logger with the name "sample". In certain circumstances, however, this logger has to be disabled (or become effectively a noop), so the property logging.enabled serves like a kill switch here. We use Slf4j and Logback in this example, but it is not really important. The LoggingConfiguration snippet below reflects this idea.
@Configuration
public class LoggingConfiguration {
@Configuration
@ConditionalOnProperty(name = "logging.enabled", matchIfMissing = true)
public static class Slf4jConfiguration {
@Bean
Logger logger() {
return LoggerFactory.getLogger("sample");
}
}
@Bean
@ConditionalOnMissingBean
Logger logger() {
return new NOPLoggerFactory().getLogger("sample");
}
}
So, how would we test that? Spring Boot (and the Spring Framework, in general) has always offered the outstanding test scaffolding support. The @SpringBootTest
and @TestPropertySource
annotations allow us to quickly bootstrap the application context with the customized properties. There is one issue though: They are applied per test class level, not a per test method. It certainly makes sense, but this basically requires you to create a test class per combination of conditionals.
If you are still with JUnit 4.x, there is one trick you may found useful that exploits Enclosed runner, the hidden gem of the framework.
@RunWith(Enclosed.class)
public class LoggingConfigurationTest {
@RunWith(SpringRunner.class)
@SpringBootTest
public static class LoggerEnabledTest {
@Autowired private Logger logger;
@Test
public void loggerShouldBeSlf4j() {
assertThat(logger).isInstanceOf(ch.qos.logback.classic.Logger.class);
}
}
@RunWith(SpringRunner.class)
@SpringBootTest
@TestPropertySource(properties = "logging.enabled=false")
public static class LoggerDisabledTest {
@Autowired private Logger logger;
@Test
public void loggerShouldBeNoop() {
assertThat(logger).isSameAs(NOPLogger.NOP_LOGGER);
}
}
}
You still have the class per condition, but at least they are all in the same nest. With JUnit 5.x, some things got easier but not to the level as one might expect. Unfortunately, Spring Boot 1.5.x does not support JUnit 5.x natively, so we have to rely on the extension provided by spring-test-junit5 community module. Here are the relevant changes in pom.xml, please notice that JUnit is explicitly excluded from the spring-boot-starter-test dependencies graph.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.github.sbrannen</groupId>
<artifactId>spring-test-junit5</artifactId>
<version>1.5.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
The test case itself is not very different besides the usage of the @Nested
annotation, which comes from JUnit 5.x to support tests as inner classes.
public class LoggingConfigurationTest {
@Nested
@ExtendWith(SpringExtension.class)
@SpringBootTest
@DisplayName("Logging is enabled, expecting Slf4j logger")
public static class LoggerEnabledTest {
@Autowired private Logger logger;
@Test
public void loggerShouldBeSlf4j() {
assertThat(logger).isInstanceOf(ch.qos.logback.classic.Logger.class);
}
}
@Nested
@ExtendWith(SpringExtension.class)
@SpringBootTest
@TestPropertySource(properties = "logging.enabled=false")
@DisplayName("Logging is disabled, expecting NOOP logger")
public static class LoggerDisabledTest {
@Autowired private Logger logger;
@Test
public void loggerShouldBeNoop() {
assertThat(logger).isSameAs(NOPLogger.NOP_LOGGER);
}
}
}
If you try to run the tests from the command line using Apache Maven and Maven Surefire plugin, you might be surprised to see that none of them were executed during the build. The issue is that ... all nested classes are excluded ... so we need to put in place another workaround.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<excludes>
<exclude />
</excludes>
</configuration>
</plugin>
With that, things should be rolling smoothly. But enough about legacy, the Spring Boot 2.1.x comes as the complete game-changer. The family of the context runners, ApplicationContextRunner
, ReactiveWebApplicationContextRunner
, and WebApplicationContextRunner
, provide an easy and straightforward way to tailor the context on per test method level, keeping the test executions incredibly fast.
public class LoggingConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(UserConfigurations.of(LoggingConfiguration.class));
@Test
public void loggerShouldBeSlf4j() {
runner
.run(ctx ->
assertThat(ctx.getBean(Logger.class)).isInstanceOf(Logger.class)
);
}
@Test
public void loggerShouldBeNoop() {
runner
.withPropertyValues("logging.enabled=false")
.run(ctx ->
assertThat(ctx.getBean(Logger.class)).isSameAs(NOPLogger.NOP_LOGGER)
);
}
}
It looks really great. The JUnit 5.x support in Spring Boot 2.1.x is much better, and with the upcoming 2.2 release, JUnit 5.x will be the default engine (not to worry, the old JUnit 4.x will still be supported). As of now, the switch to JUnit 5.x needs a bit of work on dependencies side.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
As an additional step, you may need to use recent Maven Surefire plugin, 2.22.0 or above, with out-of-the-box JUnit 5.x support. The snippet below illustrates that.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
The sample configuration we have worked with is pretty naive, many of the real-world applications would end up with quite complex contexts built out of many conditionals. The flexibility and enormous opportunities that come out of the context runners, the invaluable addition to the Spring Boot 2.x test scaffolding, are just the live savers, please keep them in mind.
The complete project sources are available on GitHub.
Published at DZone with permission of Andriy Redko, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments