Version information
Spring-boot: 2.2.6.RELEASE
Reproduction
I have a very basic immutable @ConfigurationProperties
class:
package com.example.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties
@ConstructorBinding
public class VerySimpleConfigurationProperties {
private final String someStringProperty;
public VerySimpleConfigurationProperties(@DefaultValue("default_value") String someStringProperty) {
this.someStringProperty = someStringProperty;
}
public String getSomeStringProperty() {
return someStringProperty;
}
}
When I try to bind empty properties using Binder
the binding fails. A test illustrating my expectations:
package com.example.demo;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
public class PropertiesTest {
@Test
void bindsDefaults() {
Map<String, String> properties = new HashMap<>();
Binder binder = new Binder(new MapConfigurationPropertySource(properties));
BindResult<VerySimpleConfigurationProperties> bindResult =
binder.bind("", Bindable.of(VerySimpleConfigurationProperties.class));
assertThat(bindResult)
.hasFieldOrPropertyWithValue("bound", true)
.hasFieldOrPropertyWithValue("value.someStringProperty", "default_value");
}
}
Expected result
Configuration properties are bound (bindResult.isBound()
is true), bindResult.get().getSomeStringProperty()
returns default_value
.
someStringProperty
is null
when I remove the @DefaultValue
annotation form the constructor parameter.
Comment From: mbhave
@jannis-baratheon This is behaving as designed. The bind
method will return null if no properties are bound. If you want the instance to be created even if no properties are bound, take a look at the bindOrCreate
method on the Binder
. This will give you an instance of VerySimpleConfigurationProperties
and honor any @DefaultValue
annotations that are present.
Comment From: jannis-baratheon
@mbhave Of curiosity: could you kindly share the rationale behind having two methods here? What is the use case for the non-creating bind method?
IMHO immutable configuration properties and Binder need more documentation (with practical examples). I trust that the Team has it on their roadmap. Looking forward to it
Comment From: wilkinsona
I trust that the Team has it on their roadmap
@jannis-baratheon I doubt it was intentional, but this came across to me as a little passive-aggressive. We're more than happy to improve the reference documentation and javadoc if you have some concrete suggestions that we can act upon.
The bindOrCreate
method notes that it will "create a new instance using the type of the Bindable
if the result of the binding is null
". The bind
method does not perform any such creation. What could we change to make this clearer?
Comment From: jannis-baratheon
@wilkinsona i'm not a native English speaker and maybe that's why I didn't notice the offensive tone in what I wrote. anyway it was not my intention. what I meant is that I think you're doing a great job and I'm sure that the documentation will improve and that I'm looking forward to it. I also know that any imperfections of the docs are caused by the fact that this is a relatively new feature. I'll gladly help with that if you need my help at all.
I'll try to express my expectations later on when I get near my laptop. sounds good?
Comment From: wilkinsona
@jannis-baratheon Thanks very much. The definitely sounds good to me.
Comment From: jannis-baratheon
Hi @wilkinsona, I'm back.
-
Why is there need for
Binder#bind
method at all? Why isn'tbindOrCreate
the only method inBinder
's interface? In which scenario one can benefit fromBinder#bind
returning aBindResult
with no value? Is it an exceptional situation (I guess not)? -
My Searchitsu is quite advanced, but I couldn't find a guide to using
Binder
in ConfigurationProperties testing. In fact one of the wery few places that mentionBinder
at all is the Spring-boot 2 migration guide. It would be great to have a section in the reference documentation including official ConfigurationProperties testing patterns (and maybe a bit about how this was done before and how to migrate fromPropertiesConfigurationFactory
).
I, for example, use the following pattern to test the binding and validation of ConfigurationProperties:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.validation.ValidationBindHandler;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = {ValidationAutoConfiguration.class})
class SomeConfigurationPropertiesTest {
@Autowired
private LocalValidatorFactoryBean localValidatorFactoryBean;
@Test
void bindsProperty() {
// given
Map<String, String> properties = new HashMap<>();
properties.put("some-property", "some value");
// when
SomeConfigurationProperties someConfigurationProperties =
bind(properties);
// then
assertThat(someConfigurationProperties)
.hasFieldOrPropertyWithValue("someProperty",
"some value");
}
@Test
void throwsWhenTryingToBindAnInvalidPropertyValue() {
// given
Map<String, String> properties = new HashMap<>();
properties.put("some-property", "");
// when
Throwable thrownWhenTryingToBindAnInvalidPropertyValue =
catchThrowable(() -> bind(properties));
// then
assertThat(thrownWhenTryingToBindAnInvalidPropertyValue)
.isInstanceOf(BindException.class);
}
private SomeConfigurationProperties bind(Map<String, String> properties) {
Binder binder = new Binder(new MapConfigurationPropertySource(properties));
ValidationBindHandler validationBindHandler = new ValidationBindHandler(localValidatorFactoryBean);
return binder.bindOrCreate("",
Bindable.of(SomeConfigurationProperties.class),
validationBindHandler);
}
}
SomeConfigurationProperties:
import javax.validation.constraints.NotEmpty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
@ConfigurationProperties
@ConstructorBinding
public class SomeConfigurationProperties {
@NotEmpty
private final String someProperty;
public SomeConfigurationProperties(String someProperty) {
this.someProperty = someProperty;
}
}
It took me quite some time to figure this pattern out (thanks to third-party tutorials and Stack Overflow). IMHO it's worth having this in the official documentation (or maybe there's a better way?). You have my full consent if you wish to use the above example.
Comment From: wilkinsona
There are several places in Boot's codebase where we make use of the BindResult
that is returned by bind
. It allows different behaviour depending on whether something was bound or not. For example:
binder.bind("spring.data.cassandra.schema-action", SchemaAction.class).ifBound(session::setSchemaAction);
This functionality isn't possible with bindOrCreate
where you can't tell if the result is something that was bound or a default instance that was created.
We don't expect the Binder
to be used to test the binding of @ConfigurationProperties
. You're largely testing Spring Boot at this point rather than your own code. Generally speaking, you should consider the use of the Binder
to bind to @ConfigurationProperties
to be an implementation detail. If you want to write a test that checks things like validation, @DefaultValue
and the like, I would write an integration test that creates an application context. This will ensure that you're testing the binding exactly as it will be performed at runtime rather than relying on your code that uses the Binder
directly remaining in sync with Boot's own binding code. Boot's ApplicationContextRunner
can help with such testing.
Comment From: jannis-baratheon
@wilkinsona One last question: why the need for BindResult
class? It's almost the same as Optional
. Is there a reason for this?
Comment From: wilkinsona
I didn't implement BindResult
(@philwebb and @mbhave did that), but IMO the slight differences between the BindResult
API and the Optional
API improve the readability of the code. For example, consider bind(…).ifPresent(…)
vs bind(…).ifBound(…)
. I think the second is clearer and more expressive. It also provides an opportunity to provide javadoc for each method that is specific to binding. FWIW, we do something similar with various …Customizer
interfaces. They could be Consumer
s but we prefer them not to be as it makes the API more expressive (accept
vs customize
) and allows us to provide better javadoc.
Comment From: jannis-baratheon
You're largely testing Spring Boot at this point rather than your own code. Generally speaking, you should consider the use of the
Binder
to bind to@ConfigurationProperties
to be an implementation detail.
We're new to SB v2 in our projects. We used to use PropertiesConfigurationFactory
in properties testing to have a minimal set up needed to test property binding (alternatives being too heavy and cumbersome before v2). Simply replacing PropertiesConfigurationFactory
with Binder
was the fastest way to upgrade. Now that we know we will surely start using ApplicationContextRunner
in our tests - it looks promising. In fact it would be great to have something like that for Spring projects (non-SB). Do you think that using spring-boot-test to test a Spring application could work? Or maybe there's a plan to move this to the upstream?
Comment From: philwebb
I think it should work fine in non Spring Boot applications as a test only dependency. We've no plans to push it upstream in the near-term since it relies heavily on AssertJ and also needs other test classes that only Spring Boot provides (such as FilteredClassLoader
).
Comment From: jannis-baratheon
Thanks for all the replies @philwebb @wilkinsona @mbhave it was a pleasure.