home / skills / giuseppe-trisciuoglio / developer-kit / unit-test-config-properties

This skill helps you validate @ConfigurationProperties bindings with ApplicationContextRunner, ensuring proper binding, validation, defaults, profiles, and

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill unit-test-config-properties

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
14.8 KB
---
name: unit-test-config-properties
description: Provides patterns for unit testing @ConfigurationProperties classes with @ConfigurationPropertiesTest. Use when validating application configuration binding and validation.
category: testing
tags: [junit-5, configuration-properties, spring-profiles, property-binding]
version: 1.0.1
allowed-tools: Read, Write, Bash, Glob, Grep
---

# 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

1. **Use ApplicationContextRunner**: Test property bindings without starting full Spring context
2. **Test all property paths**: Verify each property including nested structures and collections
3. **Test validation constraints**: Ensure @Validated properties fail with invalid values
4. **Test type conversions**: Verify Duration, DataSize, and other special types convert correctly
5. **Test default values**: Verify properties have correct defaults when not specified
6. **Test profile-specific configs**: Use @Profile to test environment-specific configurations
7. **Verify property prefixes**: Ensure the prefix in @ConfigurationProperties matches test properties
8. **Test edge cases**: Include empty strings, null values, and type mismatches

## Examples

## Setup: Configuration Testing

### Maven
```xml
<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
```kotlin
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

```java
// 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

```java
@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

```java
@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",
        "[email protected]"
      )
      .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

```java
@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

```java
@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

```java
@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](https://docs.spring.io/spring-boot/docs/current/reference/html/configuration-metadata.html)
- [ApplicationContextRunner Testing](https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/context/runner/ApplicationContextRunner.html)
- [Spring Profiles](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.profiles)

Overview

This skill provides concise, battle-tested patterns for unit testing Spring @ConfigurationProperties classes using ApplicationContextRunner and @ConfigurationPropertiesTest techniques. It helps validate property binding, type conversion, nested structures, defaults, validation constraints, and profile-specific wiring without starting a full Spring context. Use it to keep configuration tests fast, focused, and reliable.

How this skill works

The patterns use ApplicationContextRunner to bootstrap minimal contexts with explicit property values and beans, then assert bound properties or context failures. Tests cover simple bindings, nested objects and collections, type conversions (Duration, DataSize, Charset), validation via @Validated, default-value behavior, and profile-specific user configurations. Failures are asserted by checking context.hasFailed() and inspecting the failure message when relevant.

When to use it

  • Verify @ConfigurationProperties binding and prefixes without loading the full application
  • Assert type conversions for Duration, DataSize, lists, maps, and enums
  • Validate @Validated constraints (@NotBlank, @Min, @Max, @Email, @Positive) on config classes
  • Test nested properties, lists of sub-objects, and default values
  • Confirm profile-specific beans or settings load correctly in prod/dev scenarios

Best practices

  • Use ApplicationContextRunner.withPropertyValues(...) and .withBean(...) to isolate tests
  • Cover all property paths including nested fields and indexed list entries
  • Assert both success and failure cases for validation constraints
  • Test default values by not providing properties and then by overriding them
  • Include edge cases: empty strings, nulls, type mismatches and missing required props
  • Keep tests focused and fast — each runner instance should validate one scenario

Example use cases

  • Bind and assert security settings (jwtSecret, expiration, two-factor flag) from properties
  • Validate server configuration fails when host is blank or port is out of range
  • Verify nested database pool settings and a list of replica entries bind correctly
  • Confirm Duration and DataSize strings convert to Java types as expected
  • Load dev vs prod configuration via spring.profiles.active and verify bean values

FAQ

How do I test validation failures for a @Validated config class?

Provide invalid property values via ApplicationContextRunner.withPropertyValues(...). Run the context and assert context.hasFailed() and that the failure message contains the violated field name.

Can I test @ConstructorBinding classes with these patterns?

Yes, but ensure all constructor parameters are bindable. Use ApplicationContextRunner with the @ConfigurationProperties bean and supply all required properties in tests.