spring-boot
spring-boot copied to clipboard
Failed to bind properties in 3.0.2 to Kotlin data class during test as no setter found
I'm not exactly sure where the problem lies, but we have a test case that was running in Spring Boot 3.0.0, and fails with 3.0.2.
GitHub repo with a tiny reproduction: https://github.com/orrc/spring-boot-prop-binding-repro
With a @ConfigurationProperties
class:
@ConfigurationProperties("app.my-config")
data class SomeConfig(
val someProperty: Int,
)
And values in application.yml
:
app:
myConfig:
someProperty: 1
Running this test, with an overridden config, fails to start:
@TestConfiguration
class SomeTestConfig {
@Bean
fun someConfig() = SomeConfig(
someProperty = 2,
)
}
@WebMvcTest(Controller::class)
@Import(SomeTestConfig::class)
class ControllerTest {
@Test
fun doNothing() {
// Fails to start on Spring Boot 3.0.2
}
}
Logs:
***************************
APPLICATION FAILED TO START
***************************
Description:
Failed to bind properties under 'app.my-config' to com.example.demo.SomeConfig:
Property: app.my-config.some-property
Value: "1"
Origin: class path resource [application.yml] - 3:19
Reason: java.lang.IllegalStateException: No setter found for property: some-property
2023-01-24T22:44:22.453+01:00 ERROR 71630 --- [ Test worker] o.s.test.context.TestContextManager : Caught exception while allowing TestExecutionListener [org.springframework.test.context.support.DependencyInjectionTestExecutionListener] to prepare test instance [com.example.demo.ControllerTest@2e7af36e]
java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@4a058df8 testClass = com.example.demo.ControllerTest, locations = [], classes = [com.example.demo.DemoApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceLocations = [], propertySourceProperties = ["org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@6138e79a, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@9da1, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@7e546387, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4c20d68, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5f0e9815, [ImportsContextCustomizer@4b56b031 key = [com.example.demo.SomeTestConfig, org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration, org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration, org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration, org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration, org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration, org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration, org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration, org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration, org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration, org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration, org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration, org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration, org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@37091312, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@550a1967, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@19aa2405], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:142) ~[spring-test-6.0.4.jar:6.0.4]
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:127) ~[spring-test-6.0.4.jar:6.0.4]
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:141) ~[spring-test-6.0.4.jar:6.0.4]
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:97) ~[spring-test-6.0.4.jar:6.0.4]
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:241) ~[spring-test-6.0.4.jar:6.0.4]
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:138) ~[spring-test-6.0.4.jar:6.0.4]
…
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'controller' defined in file [/Users/chris/code/demo/build/classes/kotlin/main/com/example/demo/Controller.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'someConfig': Could not bind properties to 'SomeConfig' : prefix=app.my-config, ignoreInvalidFields=false, ignoreUnknownFields=true
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:798) ~[spring-beans-6.0.4.jar:6.0.4]
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:245) ~[spring-beans-6.0.4.jar:6.0.4]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1344) ~[spring-beans-6.0.4.jar:6.0.4]
…
Caused by: org.springframework.boot.context.properties.ConfigurationPropertiesBindException: Error creating bean with name 'someConfig': Could not bind properties to 'SomeConfig' : prefix=app.my-config, ignoreInvalidFields=false, ignoreUnknownFields=true
at org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor.bind(ConfigurationPropertiesBindingPostProcessor.java:99) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization(ConfigurationPropertiesBindingPostProcessor.java:79) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:420) ~[spring-beans-6.0.4.jar:6.0.4]
…
Caused by: org.springframework.boot.context.properties.bind.BindException: Failed to bind properties under 'app.my-config' to com.example.demo.SomeConfig
at org.springframework.boot.context.properties.bind.Binder.handleBindError(Binder.java:387) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.bind(Binder.java:347) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.lambda$bindDataObject$4(Binder.java:472) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:98) ~[spring-boot-3.0.2.jar:3.0.2]
…
Caused by: java.lang.IllegalStateException: No setter found for property: some-property
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:107) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:86) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:62) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.lambda$bindDataObject$5(Binder.java:476) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder$Context.withIncreasedDepth(Binder.java:590) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder$Context.withDataObject(Binder.java:576) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.bindDataObject(Binder.java:474) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.bindObject(Binder.java:414) ~[spring-boot-3.0.2.jar:3.0.2]
at org.springframework.boot.context.properties.bind.Binder.bind(Binder.java:343) ~[spring-boot-3.0.2.jar:3.0.2]
... 140 common frames omitted
Annotating the config class constructor with @ConstructorBinding
appears to make things work as they did in 3.0.0:
--- a/src/main/kotlin/com/example/demo/SomeConfig.kt
+++ b/src/main/kotlin/com/example/demo/SomeConfig.kt
@@ -3,5 +3,6 @@ package com.example.demo
import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.boot.context.properties.bind.ConstructorBinding
@ConfigurationProperties("app.my-config")
-data class SomeConfig(
+data class SomeConfig @ConstructorBinding constructor(
val someProperty: Int,
Could be related to https://github.com/spring-projects/spring-boot/issues/33849
It might also be related to #33710
I don't think its working with 3.0.0 for me. I cannot override the default settings for the following in application.yml
:
management:
metrics:
export:
statsd:
host: ${STATSD_HOST}
enabled: true
The STATSD_HOST
environment variable is never loaded as a property override. I tried using Java properties on the command line too and that didn't work.
Only the default settings are used. This works fine for me while using 2.x.
@travispeloton Did you mean to comment on this issue? I'm not sure I see how your problem is related to Kotlin data classes.
This issue appeared relevant. I'm using Kotlin as well, and Spring 3.x wont allow me to configure the properties I mentioned in the comment above. It always uses the default values. There is something bigger broken here related to property binding.
@travispeloton This issue is specifically a regression in Spring Boot 3.0.2.
We are able to bind config properties and override them with environment variables at runtime in 3.0.x, so you should probably open a new issue with more details, including a minimal reproduction case, separate from statsd configuration.
After updating to Spring Boot 3.0.2, we were able to continue with the workaround above, and things worked fine. Now that we're trying to update to Spring Boot 3.1.2, neither our original code nor the workaround works anymore.
This feels potentially similar to #36175, though the symptoms they were seeing seem to be different. So I'm still a bit lost about exactly what the problem is, or whether we're just doing something completely wrong… 🤔
It should be possible reproduce with the GitHub repository mentioned above:
git clone https://github.com/orrc/spring-boot-prop-binding-repro
cd spring-boot-prop-binding-repro
✅ Succeeds with Spring Boot 3.0.1
Downgrade to 3.0.1 and run the tests:
sed -i 's/3.0.2/3.0.1/' build.gradle.kts
./gradlew test
# BUILD SUCCESSFUL
:x: Fails with Spring Boot 3.0.2
Reset to 3.0.2 and run the tests:
git checkout -- .
./gradlew test
# BUILD FAILED
✅ Succeeds with Spring Boot 3.0.2 + workaround
Apply the workaround to annotate the constructor with @ConstructorBinding
, and run the tests:
patch -p1 << EOF
--- a/src/main/kotlin/com/example/demo/SomeConfig.kt
+++ b/src/main/kotlin/com/example/demo/SomeConfig.kt
@@ -3,0 +4 @@ import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.boot.context.properties.bind.ConstructorBinding
@@ -6 +7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
-data class SomeConfig(
+data class SomeConfig @ConstructorBinding constructor(
EOF
./gradlew test
# BUILD SUCCESSFUL
✅ Succeeds with Spring Boot 3.1.0 + workaround
Upgrade to 3.1.0 and run the tests:
sed -i 's/3.0.2/3.1.0/' build.gradle.kts
./gradlew test
# BUILD SUCCESSFUL
:x: Fails with Spring Boot 3.1.1 + workaround
Upgrade to 3.1.1 and run the tests:
sed -i 's/3.1.0/3.1.1/' build.gradle.kts
./gradlew test
# BUILD FAILED
:x: Fails with Spring Boot 3.1.1, without workaround
Remove the workaround, and it still fails:
git checkout -- src/
./gradlew test
# BUILD FAILED
This isn't specific to Kotlin. The same problem occurs with a Java class like this:
@ConfigurationProperties("app.myconfig")
public class SomeConfig {
private final int someProperty;
public SomeConfig(int someProperty) {
this.someProperty = someProperty;
}
public int getSomeProperty() {
return this.someProperty;
}
}
Using @ConstructorBinding
to prevent the attempt at JavaBean-based binding works up until 3.0.7 inclusive. I haven't tried every version, but it would appear that it never worked in 2.7.x. It either fails with the "no setter found" error or, when adding @ConstructorBinding
, it fails because it has been added to a regular bean.
So I ran into this when trying to updated a project to 3.0.10 from 3.0.7. It appears to occur because spring instantiates the bean using the factory method (in the example repo, by calling SomeTestConfig.someConfig()
, and then tries to modify the properties of the bean from the application.yml. Since the properties are indeed immutable, the attempt to modify them from the YAML file fails. That's probably as it should be, if it's meant to be immutable. I'm not sure how it worked prior to 3.0.8, but I assume that that spring was just not trying to apply the YAML. Maybe it should be fixed to revert to that behavior? At the least, a more helpful error message might be useful.
I see the same issue with Kotlin data class when upgrading to spring-boot 3.1.5 from 2.7.12. I removed the @ConstructorBinding annotation as suggested in the 3.0 migration guide and started getting the error - Failed to bind properties under...
.
Can someone please confirm if this is being looked at?
@Vignesh-Au it sounds like you have a different problem. As I said above, I don't think the situation that this issue is tracking ever worked with 2.7.x. I believe it only worked from 3.0.0 to 3.0.7 inclusive. Please share a minimal example that works with 2.7.12 and fails with 3.1.5.
package com.example.gh_33969;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import com.example.gh_33969.Gh33969ApplicationTests.SomeConfiguration;
@SpringBootTest(classes = SomeConfiguration.class, properties = "immutable.property=some-value")
class Gh33969ApplicationTests {
@Autowired
private ImmutableProperties properties;
@Test
void contextLoads() {
System.out.println(properties.getProperty());
}
@SpringBootConfiguration
@EnableConfigurationProperties
static class SomeConfiguration {
@Bean
ImmutableProperties immutableProperties() {
return new ImmutableProperties("value");
}
}
}
@ConfigurationProperties("immutable")
class ImmutableProperties {
private final String property;
public ImmutableProperties(String property) {
this.property = property;
}
public String getProperty() {
return property;
}
}
The problem does not occur if ImmutableProperties
is a record
rather than a class
.
I ran into this with records too, I posted it as question on StackOverflow: https://stackoverflow.com/questions/78586486/spring-boot-3-3-0-configurationproperties-not-working-with-bean I thought it was something I did wrong, but could it be this issue?
It seems it cannot do constructor binding on the properties record. Nested records are not a problem. Success: class with no-arg-const+setters & record params Failure: record with record params Failure: class with RequiredArgConstructors (final params) & record params
The problem does not occur if ImmutableProperties is a record rather than a class.
This is only the case when the record does not resemble a JavaBean. If it has JavaBean-style getters, the problem occurs:
@ConfigurationProperties(prefix = "immutable")
record ImmutableProperties(String property) {
String getProperty() {
return this.property;
}
}
We can avoid the binding attempt by deducing the bind method and not binding when it's VALUE_OBJECT
. This works for the scenarios described in this issue. It does not work for the situation described in #33710 where the object can be created using constructor binding but it also has some JavaBean-style properties that need to be bound.
There are two different situations here:
- The instance returned from the
@Bean
method is fully initialized and no binding should be performed - The instance returned from the
@Bean
method is only a starting point and binding should be performed
I don't think it's possible for us to infer the first situation with 100% accuracy. We may be able to make things more accurate than they are today by skipping binding if we can detect that the bean's immutable, but that would not help in situations where the bean could be mutated but we don't want it to be.
The situation where the bean could be mutated but we don't want it to be arguably applies more broadly. In the situation in this issue's opening comment, if SomeConfig
was mutable, some-property
would be set to 1
by configuration property binding when the intent was for its value to be 2
. It feels like we need a way for configuration property binding to be disabled for an instance of a @ConfigurationProperties
-annotated class that's returned from a @Bean
method.
We discussed this today and we're considering adding a bindMode
attribute to @ConfigurationProperties
with values of DEFAULT
, JAVA_BEAN
, VALUE_OBJECT
(names can be tweaked). If the attribute is set we'll take that as a signal of the type of binding wanted.
We think this might also allow us to get rid of the @Autowired
on constructor requirement that we currently have when users don't want constructor binding.
We can also look into supporting this on the bean method as way of overriding the version declared on the class.
In the meantime, we would recommend not creating a bean for this purpose but instead use a @PropertySource
on the test and rely on the real binding.
After some discussion we're going to repurpose this issue to ensure that immutable @ConfigurationProperties
can be used with the upcoming Spring Framework @BeanOverride
annotation.
In the meantime, if using a @PropertySource
isn't an option, you can instead add a BindMethod
attribute to the bean definition with a BindMethod.VALUE_OBJECT
value which will prevent the bean based binding from happening.
Here's an updated example that does that using a ApplicationContextInitializer
:
package com.example.gh_33969;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.BindMethod;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import com.example.gh_33969.Gh33969ApplicationTests.OverrideConfigurationProperties;
import com.example.gh_33969.Gh33969ApplicationTests.SomeConfiguration;
@SpringBootTest(classes = SomeConfiguration.class, properties = "immutable.property=some-value")
@ContextConfiguration(initializers = OverrideConfigurationProperties.class)
class Gh33969ApplicationTests {
@Autowired
private ImmutableProperties properties;
@Test
void contextLoads() {
System.out.println(properties.getProperty());
}
@SpringBootConfiguration
@EnableConfigurationProperties
static class SomeConfiguration {
}
static class OverrideConfigurationProperties implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext applicationContext) {
applicationContext.registerBean(ImmutableProperties.class,
() -> new ImmutableProperties("value"),
this::disableBeanBinding);
}
private void disableBeanBinding(BeanDefinition beanDefinition) {
beanDefinition.setAttribute(BindMethod.class.getName(), BindMethod.VALUE_OBJECT);
}
}
}
@ConfigurationProperties("immutable")
class ImmutableProperties {
private final String property;
public ImmutableProperties(String property) {
this.property = property;
}
public String getProperty() {
return property;
}
}
This works as we'd hoped with Spring Framework 6.2's @TestBean
:
package com.example.gh_33969;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.bean.override.convention.TestBean;
@SpringBootTest(properties = "immutable.property=some-value")
class Gh33969ApplicationTests {
@TestBean
private ImmutableProperties properties;
@Test
void contextLoads() {
System.out.println(this.properties.getProperty());
}
static ImmutableProperties properties() {
return new ImmutableProperties("test-bean");
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ImmutableProperties.class)
static class SomeConfiguration {
SomeConfiguration(ImmutableProperties properties) {
System.out.println(properties.getProperty());
}
}
}
@ConfigurationProperties("immutable")
class ImmutableProperties {
private final String property;
public ImmutableProperties(String property) {
this.property = property;
}
public String getProperty() {
return this.property;
}
}
When run, the above will output test-bean
twice. If @TestBean
is replaced with @Autowired
, it will output some-value
twice. I've just pushed a test that verifies this behavior.