Overriding the configuration of a Quarkus app from its test code
This post was initially published in the Quarkus blog. |
Overriding the configuration of a Quarkus app from its test code is often required to achieve a good test coverage. Whenever a config property determines how the app behaves, all possible config values need to be tested.
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class MyService {
@Inject
MyConfig config;
public void doSomething() {
if (config.newFeatureEnabled()) {
// This branch needs to be tested.
} else {
// So does that branch.
}
}
}
@ConfigMapping(prefix = "my-config")
interface MyConfig { (1)
@WithDefault("false")
boolean newFeatureEnabled();
}
1 | In a real project, this interface would likely be public and declared in a separate file. |
There are many ways to override the configuration from the test code. This post will show you five approaches, with a particular focus on the benefits and drawbacks of each of them.
All code snippets from this post (and more!) are available in the gwenneg/blog-overriding-configuration-from-test-code repository. |
Approach #1: Quarkus test profiles
Quarkus test profiles are one of the best ways to override the configuration. They can be used while testing in native mode, unlike most approaches listed in this post. In addition to the config override, they provide many additional capabilities which make it easier to test Quarkus apps.
From a configuration override perspective, test profiles suffer however from a few drawbacks. First, Quarkus is restarted before each test profile is used, which obviously slows down the tests execution. The tests also have to be split into several test profiles and classes to cover multiple values of the same config properties. As a result, bigger projects may end up with lots of test profiles and spend a lot of time restarting Quarkus between tests. Maintaining or reviewing the test code may also be more challenging with test profiles.
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@Path("/features")
public class FeaturesResource {
@Inject
FeaturesConfig featuresConfig; (1)
@ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") (1)
boolean amazingFeatureEnabled;
@GET
@Path("/awesome")
public boolean isAwesomeFeatureEnabled() {
return featuresConfig.awesomeFeatureEnabled();
}
@GET
@Path("/amazing")
public boolean isAmazingFeatureEnabled() {
return amazingFeatureEnabled;
}
}
@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)
@WithDefault("false")
boolean awesomeFeatureEnabled();
}
1 | Test profiles work with both config mappings and @ConfigProperty . |
2 | In a real project, this interface would likely be public and declared in a separate file. |
Most guides about test profiles will introduce them in a verbose way to demonstrate all their capabilities. A test profile can actually be added to an existing test class with only a few extra lines:
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import java.util.Map;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
@QuarkusTest
@TestProfile(FeaturesResourceTest.class)
public class FeaturesResourceTest implements QuarkusTestProfile { (1)
@Override
public Map<String, String> getConfigOverrides() { (2)
return Map.of(
"features.awesome-feature-enabled", "true", (3)
"amazing-feature-enabled", "true"
);
}
@Test
void test() {
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true"));
RestAssured.given()
.when().get("/features/amazing")
.then().body(CoreMatchers.is("true"));
}
}
1 | The test class itself can implement QuarkusTestProfile if the profile isn’t shared across multiple test classes.
This can make the maintenance and reviews of the test code easier.
If multiple test classes depend on the same profile, then that profile will likely need to be declared in a dedicated class. |
2 | This method comes from QuarkusTestProfile and makes it possible to override the configuration from the test code. |
3 | The config key generated from the FeaturesConfig interface is prefixed with features. while the config key that comes from the @ConfigProperty injection has no prefix. |
Test profiles can also leverage profile aware files to override the configuration from the test code:
features.awesome-feature-enabled=true
When that is used, the test profile needs to override the default config profile:
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
@QuarkusTest
@TestProfile(FeaturesResourceTest.class)
public class FeaturesResourceTest implements QuarkusTestProfile {
@Override
public String getConfigProfile() { (1)
return "blog"; (2)
}
@Test
void test() {
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true"));
}
}
1 | This method comes from QuarkusTestProfile and makes it possible to override the default config profile. |
2 | The application-blog.properties file will be loaded because the blog config profile is active. |
If the tests are run in JVM mode only and not in native mode, the application-blog.properties
file can be placed in the src/test/resources
folder.
An additional application.properties
file (possibly empty) is also required in the same location to enable profile aware files.
If the tests are run in native mode, the same application-blog.properties
and application.properties
files are needed as well, but they have to be placed in the src/main/resources
folder.
The application.properties
file also needs to contain the following line:
quarkus.native.resources.includes=application*.properties
Approach #2: mocking the config with Mockito
Now, here’s my favorite approach when native testing is not required.
First, let’s see how that works with a config mapping:
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/features")
public class FeaturesResource {
@Inject
FeaturesConfig featuresConfig;
@GET
@Path("/awesome")
public boolean isAwesomeFeatureEnabled() {
return featuresConfig.awesomeFeatureEnabled();
}
}
@ConfigMapping(prefix = "features")
interface FeaturesConfig { (1)
@WithDefault("false")
boolean awesomeFeatureEnabled();
}
1 | In a real project, this interface would likely be public and declared in a separate file. |
import io.quarkus.test.InjectMock;
import io.quarkus.test.Mock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.smallrye.config.SmallRyeConfig;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@QuarkusTest
public class FeaturesResourceTest {
@Inject
SmallRyeConfig smallRyeConfig;
@Produces (1)
@ApplicationScoped
@Mock
FeaturesConfig featuresConfig() { (2)
return smallRyeConfig.getConfigMapping(FeaturesConfig.class);
}
@InjectMock (3)
FeaturesConfig featuresConfig;
@Test
void test() {
Mockito.when(featuresConfig.awesomeFeatureEnabled()).thenReturn(true); (4)
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true"));
}
}
1 | This annotation can be omitted. |
2 | This is required to make the FeaturesConfig interface implementation proxyable.
Without that, it wouldn’t be possible to mock it with @InjectMock . |
3 | The config class is mocked with the help of the quarkus-junit5-mockito extension.
Injections are not supported in tests in native mode, so this only works when the test is run in JVM mode. |
4 | The configuration can be mocked from the test method or from a method annotated with one of JUnit’s lifecycle annotations such as @BeforeEach . |
What if your project relies on @ConfigProperty
instead of @ConfigMapping
?
Well, that works too!
You’ll just need to move the config properties to an extra @ApplicationScoped
bean.
That bean may or may not be used to centralize all config properties from the Quarkus app.
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.Startup;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class FeaturesConfig {
private static final String AWESOME_FEATURE_ENABLED = "awesome-feature-enabled";
@ConfigProperty(name = AWESOME_FEATURE_ENABLED, defaultValue = "false")
boolean awesomeFeatureEnabled;
// Omitted: additional config properties.
public boolean isAwesomeFeatureEnabled() {
return awesomeFeatureEnabled;
}
// This is an optional bonus unrelated to the blog post topic.
void logConfigAtStartup(@Observes Startup event) { (1)
Map<String, Object> config = new TreeMap<>(); (2)
config.put(AWESOME_FEATURE_ENABLED, awesomeFeatureEnabled);
// Omitted: put all config keys and values into the map.
Log.info("=== Startup configuration ===");
config.forEach((key, value) -> {
Log.infof("%s=%s", key, value); (3)
});
}
}
1 | This method is executed at application startup. See the Application initialization and termination guide for more details about the application lifecycle events. |
2 | TreeMap helps automatically sort the map entries by keys alphabetically. |
3 | The application config is logged at startup. This can really help if you ever need to investigate an issue based on past logs. Be careful not to log any sensitive config values though! (e.g. secrets or passwords) |
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/features")
public class FeaturesResource {
@Inject
FeaturesConfig featuresConfig;
@GET
@Path("/awesome")
public boolean isAwesomeFeatureEnabled() {
return featuresConfig.isAwesomeFeatureEnabled();
}
}
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@QuarkusTest
public class FeaturesResourceTest {
@InjectMock (1)
FeaturesConfig featuresConfig;
@Test
void test() {
Mockito.when(featuresConfig.isAwesomeFeatureEnabled()).thenReturn(true); (2)
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true"));
}
}
1 | The config class is mocked with the help of the quarkus-junit5-mockito extension.
Injections are not supported in tests in native mode, so this only works when the test is run in JVM mode. |
2 | The configuration can be mocked from the test method or from a method annotated with one of JUnit’s lifecycle annotations such as @BeforeEach . |
This approach can also leverage the @ParameterizedTest
feature from JUnit and test several values of a config property with a single test method:
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
@QuarkusTest
public class FeaturesResourceTest {
@InjectMock
FeaturesConfig featuresConfig;
@ParameterizedTest
@ValueSource(booleans = {true, false})
void test(boolean awesomeFeatureEnabled) { (1)
Mockito.when(featuresConfig.isAwesomeFeatureEnabled()).thenReturn(awesomeFeatureEnabled);
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is(String.valueOf(awesomeFeatureEnabled)));
}
}
1 | When the tests are run, this method will be invoked once for each value provided with the @ValueSource annotation. |
Approach #3: constructor injection
What if you need native testing in a big project that suffers from the Quarkus test profiles drawbacks mentioned earlier in this post? Injecting the configuration through your CDI beans constructors might be the right approach for you.
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@Singleton
public class FeaturesService {
private final FeaturesConfig featuresConfig;
private final boolean amazingFeatureEnabled;
public FeaturesService( (1)
FeaturesConfig featuresConfig,
@ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") boolean amazingFeatureEnabled
) {
this.featuresConfig = featuresConfig;
this.amazingFeatureEnabled = amazingFeatureEnabled;
}
public boolean isAwesomeFeatureEnabled() {
return featuresConfig.awesomeFeatureEnabled();
}
public boolean isAmazingFeatureEnabled() {
return amazingFeatureEnabled;
}
}
@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)
@WithDefault("false")
boolean awesomeFeatureEnabled();
}
1 | The configuration is injected in the constructor of the CDI bean.
This approach works with both config mappings and @ConfigProperty . |
2 | In a real project, this interface would likely be public and declared in a separate file. |
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@QuarkusTest
public class FeaturesServiceTest {
@Test
void test() {
FeaturesConfig featuresConfig = new FeaturesConfig() { (1)
@Override
public boolean awesomeFeatureEnabled() {
return true;
}
};
FeaturesService featuresService = new FeaturesService(featuresConfig, true); (2)
Assertions.assertTrue(featuresService.isAwesomeFeatureEnabled());
Assertions.assertTrue(featuresService.isAmazingFeatureEnabled());
}
}
1 | This is used to override the configuration from the FeaturesConfig interface. |
2 | The configuration is overridden from the test when the bean constructor is invoked.
The first argument overrides the configuration that relies on @ConfigMapping .
The second argument overrides the configuration that relies on @ConfigProperty . |
With this approach, no injections will be performed by CDI when the tests are run because the bean is instantiated manually instead of being managed by the CDI container from Quarkus. That drawback can be mitigated by injecting all dependencies (other beans and/or configuration) through the constructor of the tested bean. When that is done, CDI injections still won’t work but the test code will be able to provide all dependencies required for the test execution.
Approach #4: testing components
Quarkus recently introduced an experimental feature called Testing components which can be used to override the configuration from the test code.
That feature is provided by the quarkus-junit5-component
extension.
This approach doesn’t start the full Quarkus app.
It only starts the CDI container and injects the fields from the test which are annotated with @jakarta.inject.Inject
or @io.quarkus.test.InjectMock
.
It can therefore be much faster, especially in bigger projects, than the full Quarkus app restarts that come with Quarkus test profiles.
This approach doesn’t work with native testing because it relies on injections in the test code, which are only supported when the tests are run in JVM mode.
Let’s see how that works:
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class FeaturesService {
@Inject
FeaturesConfig featuresConfig; (1)
@ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") (1)
boolean amazingFeatureEnabled;
public boolean isAwesomeFeatureEnabled() {
return featuresConfig.awesomeFeatureEnabled();
}
public boolean isAmazingFeatureEnabled() {
return amazingFeatureEnabled;
}
}
@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)
@WithDefault("false")
boolean awesomeFeatureEnabled();
}
1 | Testing components works with both config mappings and @ConfigProperty . |
2 | In a real project, this interface would likely be public and declared in a separate file. |
import io.quarkus.test.component.QuarkusComponentTest;
import io.quarkus.test.component.TestConfigProperty;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@QuarkusComponentTest (1)
@TestConfigProperty(key = "features.awesome-feature-enabled", value = "true") (2)
public class FeaturesServiceTest {
@Inject
FeaturesService featuresService;
@Test
@TestConfigProperty(key = "amazing-feature-enabled", value = "true") (2)
void test() {
Assertions.assertTrue(featuresService.isAwesomeFeatureEnabled());
Assertions.assertTrue(featuresService.isAmazingFeatureEnabled());
}
}
1 | The usual @QuarkusTest annotation has been replaced with @QuarkusComponentTest . |
2 | @TestConfigProperty can be used on the test class, a test method or both. |
Approach #5: system properties
I would definitely NOT recommend this approach, but it does exist and it kinda works, so I’ll mention it anyway. System properties can be used to override the configuration from the test code. This approach suffers however from major drawbacks:
-
It doesn’t work in native mode.
-
It doesn’t work with config mappings.
-
It only works once when the configuration is defined in an
@ApplicationScoped
or@Singleton
bean, before that bean has been initialized. After the bean initialization, any changes made to system properties will have no effect on the configuration.
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@Path("/features")
public class FeaturesResource {
@ConfigProperty(name = "awesome-feature-enabled", defaultValue = "false")
boolean awesomeFeatureEnabled;
@GET
@Path("/awesome")
public boolean isAwesomeFeatureEnabled() {
return awesomeFeatureEnabled;
}
}
System properties can be set from the command line with Maven or Gradle:
./mvnw verify -Dawesome-feature-enabled=true
They can also be set from the test code:
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) (1)
class FeaturesResourceTest {
@Test
@Order(1) (2)
void firstTest() {
System.setProperty("awesome-feature-enabled", "true");
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true"));
}
@Test
@Order(2) (3)
void lastTest() {
System.setProperty("awesome-feature-enabled", "false");
RestAssured.given()
.when().get("/features/awesome")
.then().body(CoreMatchers.is("true")); (4)
}
}
1 | In this code snippet, tests are run in a fixed order to demonstrate a limitation of system properties. |
2 | This test always runs first. |
3 | This test always runs last. |
4 | This test depends on a CDI bean with a default @Singleton scope which was already initialized by the previous test.
As a consequence, the outcome of this test cannot be changed from the system property. |
Conclusion
First, this post is not a comprehensive list of all existing approaches to override the configuration from the test code. There are additional options such as using reflection (hardly maintainable) which I did not include, and probably approaches I’m not even aware of. Please don’t hesitate to share your experience and opinion about this topic in the comments!
Most of you probably started reading this post with a question in mind: what is the best approach? Well, as you probably understood through the post, none of them is perfect (yet). They all come with drawbacks. In my experience, the real question is not about picking the best approach, but rather about how to better combine different approaches and use the best they each have to offer.
If you’re unsure about which approach you may introduce in your project, the gwenneg/blog-overriding-configuration-from-test-code repository might help you make that decision. It contains an implementation of all approaches mentioned in this post.
Thanks for reading this post! I hope it will help you better test your Quarkus apps.
Leave a comment