Unit Testing Configuration Properties and Profiles
Overview
This skill provides patterns for unit testing @ConfigurationProperties bindings, environment-specific configurations, and property validation using JUnit 5. It covers testing property name mapping, type conversions, validation constraints, nested structures, and profile-specific configurations without full Spring context startup.
When to Use
Use this skill when:
-
Testing @ConfigurationProperties property binding
-
Testing property name mapping and type conversions
-
Verifying configuration validation
-
Testing environment-specific configurations
-
Testing nested property structures
-
Want fast configuration tests without Spring context
Instructions
-
Use ApplicationContextRunner: Test property bindings without starting full Spring context
-
Test all property paths: Verify each property including nested structures and collections
-
Test validation constraints: Ensure @Validated properties fail with invalid values
-
Test type conversions: Verify Duration, DataSize, and other special types convert correctly
-
Test default values: Verify properties have correct defaults when not specified
-
Test profile-specific configs: Use @Profile to test environment-specific configurations
-
Verify property prefixes: Ensure the prefix in @ConfigurationProperties matches test properties
-
Test edge cases: Include empty strings, null values, and type mismatches
Examples
Setup: Configuration Testing
Maven
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <scope>test</scope> </dependency>
Gradle
dependencies { annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.assertj:assertj-core") }
Basic Pattern: Testing ConfigurationProperties
Simple Property Binding
// Configuration properties class @ConfigurationProperties(prefix = "app.security") @Data public class SecurityProperties { private String jwtSecret; private long jwtExpirationMs; private int maxLoginAttempts; private boolean enableTwoFactor; }
// Unit test import org.junit.jupiter.api.Test; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.*;
class SecurityPropertiesTest {
@Test void shouldBindPropertiesFromEnvironment() { new ApplicationContextRunner() .withPropertyValues( "app.security.jwtSecret=my-secret-key", "app.security.jwtExpirationMs=3600000", "app.security.maxLoginAttempts=5", "app.security.enableTwoFactor=true" ) .withBean(SecurityProperties.class) .run(context -> { SecurityProperties props = context.getBean(SecurityProperties.class);
assertThat(props.getJwtSecret()).isEqualTo("my-secret-key");
assertThat(props.getJwtExpirationMs()).isEqualTo(3600000L);
assertThat(props.getMaxLoginAttempts()).isEqualTo(5);
assertThat(props.isEnableTwoFactor()).isTrue();
});
}
@Test void shouldUseDefaultValuesWhenPropertiesNotProvided() { new ApplicationContextRunner() .withPropertyValues("app.security.jwtSecret=key") .withBean(SecurityProperties.class) .run(context -> { SecurityProperties props = context.getBean(SecurityProperties.class);
assertThat(props.getJwtSecret()).isEqualTo("key");
assertThat(props.getMaxLoginAttempts()).isZero();
});
} }
Testing Nested Configuration Properties
Complex Property Structure
@ConfigurationProperties(prefix = "app.database") @Data public class DatabaseProperties { private String url; private String username; private Pool pool = new Pool(); private List<Replica> replicas = new ArrayList<>();
@Data public static class Pool { private int maxSize = 10; private int minIdle = 5; private long connectionTimeout = 30000; }
@Data public static class Replica { private String name; private String url; private int priority; } }
class NestedPropertiesTest {
@Test void shouldBindNestedProperties() { new ApplicationContextRunner() .withPropertyValues( "app.database.url=jdbc:mysql://localhost/db", "app.database.username=admin", "app.database.pool.maxSize=20", "app.database.pool.minIdle=10", "app.database.pool.connectionTimeout=60000" ) .withBean(DatabaseProperties.class) .run(context -> { DatabaseProperties props = context.getBean(DatabaseProperties.class);
assertThat(props.getUrl()).isEqualTo("jdbc:mysql://localhost/db");
assertThat(props.getPool().getMaxSize()).isEqualTo(20);
assertThat(props.getPool().getConnectionTimeout()).isEqualTo(60000L);
});
}
@Test void shouldBindListOfReplicas() { new ApplicationContextRunner() .withPropertyValues( "app.database.replicas[0].name=replica-1", "app.database.replicas[0].url=jdbc:mysql://replica1/db", "app.database.replicas[0].priority=1", "app.database.replicas[1].name=replica-2", "app.database.replicas[1].url=jdbc:mysql://replica2/db", "app.database.replicas[1].priority=2" ) .withBean(DatabaseProperties.class) .run(context -> { DatabaseProperties props = context.getBean(DatabaseProperties.class);
assertThat(props.getReplicas()).hasSize(2);
assertThat(props.getReplicas().get(0).getName()).isEqualTo("replica-1");
assertThat(props.getReplicas().get(1).getPriority()).isEqualTo(2);
});
} }
Testing Property Validation
Validate Configuration with Constraints
@ConfigurationProperties(prefix = "app.server") @Data @Validated public class ServerProperties { @NotBlank private String host;
@Min(1) @Max(65535) private int port = 8080;
@Positive private int threadPoolSize;
@Email private String adminEmail; }
class ConfigurationValidationTest {
@Test void shouldFailValidationWhenHostIsBlank() { new ApplicationContextRunner() .withPropertyValues( "app.server.host=", "app.server.port=8080", "app.server.threadPoolSize=10" ) .withBean(ServerProperties.class) .run(context -> { assertThat(context).hasFailed() .getFailure() .hasMessageContaining("host"); }); }
@Test void shouldFailValidationWhenPortOutOfRange() { new ApplicationContextRunner() .withPropertyValues( "app.server.host=localhost", "app.server.port=99999", "app.server.threadPoolSize=10" ) .withBean(ServerProperties.class) .run(context -> { assertThat(context).hasFailed(); }); }
@Test void shouldPassValidationWithValidConfiguration() { new ApplicationContextRunner() .withPropertyValues( "app.server.host=localhost", "app.server.port=8080", "app.server.threadPoolSize=10", "app.server.adminEmail=admin@example.com" ) .withBean(ServerProperties.class) .run(context -> { assertThat(context).hasNotFailed(); ServerProperties props = context.getBean(ServerProperties.class); assertThat(props.getHost()).isEqualTo("localhost"); }); } }
Testing Profile-Specific Configurations
Environment-Specific Properties
@Configuration @Profile("prod") class ProductionConfiguration { @Bean public SecurityProperties securityProperties() { SecurityProperties props = new SecurityProperties(); props.setEnableTwoFactor(true); props.setMaxLoginAttempts(3); return props; } }
@Configuration @Profile("dev") class DevelopmentConfiguration { @Bean public SecurityProperties securityProperties() { SecurityProperties props = new SecurityProperties(); props.setEnableTwoFactor(false); props.setMaxLoginAttempts(999); return props; } }
class ProfileBasedConfigurationTest {
@Test void shouldLoadProductionConfiguration() { new ApplicationContextRunner() .withPropertyValues("spring.profiles.active=prod") .withUserConfiguration(ProductionConfiguration.class) .run(context -> { SecurityProperties props = context.getBean(SecurityProperties.class);
assertThat(props.isEnableTwoFactor()).isTrue();
assertThat(props.getMaxLoginAttempts()).isEqualTo(3);
});
}
@Test void shouldLoadDevelopmentConfiguration() { new ApplicationContextRunner() .withPropertyValues("spring.profiles.active=dev") .withUserConfiguration(DevelopmentConfiguration.class) .run(context -> { SecurityProperties props = context.getBean(SecurityProperties.class);
assertThat(props.isEnableTwoFactor()).isFalse();
assertThat(props.getMaxLoginAttempts()).isEqualTo(999);
});
} }
Testing Type Conversion
Property Type Binding
@ConfigurationProperties(prefix = "app.features") @Data public class FeatureProperties { private Duration cacheExpiry = Duration.ofMinutes(10); private DataSize maxUploadSize = DataSize.ofMegabytes(100); private List<String> enabledFeatures; private Map<String, String> featureFlags; private Charset fileEncoding = StandardCharsets.UTF_8; }
class TypeConversionTest {
@Test void shouldConvertStringToDuration() { new ApplicationContextRunner() .withPropertyValues("app.features.cacheExpiry=30s") .withBean(FeatureProperties.class) .run(context -> { FeatureProperties props = context.getBean(FeatureProperties.class);
assertThat(props.getCacheExpiry()).isEqualTo(Duration.ofSeconds(30));
});
}
@Test void shouldConvertStringToDataSize() { new ApplicationContextRunner() .withPropertyValues("app.features.maxUploadSize=50MB") .withBean(FeatureProperties.class) .run(context -> { FeatureProperties props = context.getBean(FeatureProperties.class);
assertThat(props.getMaxUploadSize()).isEqualTo(DataSize.ofMegabytes(50));
});
}
@Test void shouldConvertCommaDelimitedListToList() { new ApplicationContextRunner() .withPropertyValues("app.features.enabledFeatures=feature1,feature2,feature3") .withBean(FeatureProperties.class) .run(context -> { FeatureProperties props = context.getBean(FeatureProperties.class);
assertThat(props.getEnabledFeatures())
.containsExactly("feature1", "feature2", "feature3");
});
} }
Testing Property Binding with Default Values
Verify Default Configuration
@ConfigurationProperties(prefix = "app.cache") @Data public class CacheProperties { private long ttlSeconds = 300; private int maxSize = 1000; private boolean enabled = true; private String cacheType = "IN_MEMORY"; }
class DefaultValuesTest {
@Test void shouldUseDefaultValuesWhenNotSpecified() { new ApplicationContextRunner() .withBean(CacheProperties.class) .run(context -> { CacheProperties props = context.getBean(CacheProperties.class);
assertThat(props.getTtlSeconds()).isEqualTo(300L);
assertThat(props.getMaxSize()).isEqualTo(1000);
assertThat(props.isEnabled()).isTrue();
assertThat(props.getCacheType()).isEqualTo("IN_MEMORY");
});
}
@Test void shouldOverrideDefaultValuesWithProvidedProperties() { new ApplicationContextRunner() .withPropertyValues( "app.cache.ttlSeconds=600", "app.cache.cacheType=REDIS" ) .withBean(CacheProperties.class) .run(context -> { CacheProperties props = context.getBean(CacheProperties.class);
assertThat(props.getTtlSeconds()).isEqualTo(600L);
assertThat(props.getCacheType()).isEqualTo("REDIS");
assertThat(props.getMaxSize()).isEqualTo(1000); // Default unchanged
});
} }
Best Practices
-
Test all property bindings including nested structures
-
Test validation constraints thoroughly
-
Test both default and custom values
-
Use ApplicationContextRunner for context-free testing
-
Test profile-specific configurations separately
-
Verify type conversions work correctly
-
Test edge cases (empty strings, null values, type mismatches)
Common Pitfalls
-
Not testing validation constraints
-
Forgetting to test default values
-
Not testing nested property structures
-
Testing with wrong property prefix
-
Not handling type conversion properly
Constraints and Warnings
-
Property name matching: Kebab-case in properties (app.my-prop) maps to camelCase in Java (myProp)
-
Loose binding by default: Spring Boot supports loose binding; enable strict binding if needed
-
Validation requires @Validated: Add @Validated to enable validation on configuration properties
-
@ConstructorBinding limitations: When using @ConstructorBinding, all parameters must be bindable
-
List indexing: List properties use [0], [1] notation; ensure sequential indexing
-
Duration format: Duration properties accept standard ISO-8601 format or simple syntax (10s, 1m)
-
ApplicationContextRunner isolation: Each ApplicationContextRunner creates a new context; there's no shared state
Troubleshooting
Properties not binding: Verify prefix and property names match exactly (including kebab-case to camelCase conversion).
Validation not triggered: Ensure @Validated is present and validation dependencies are on classpath.
ApplicationContextRunner not found: Verify spring-boot-starter-test is in test dependencies.
References
-
Spring Boot ConfigurationProperties
-
ApplicationContextRunner Testing
-
Spring Profiles