diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc index 00c96ea941c1..14aa8cd9aba3 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc @@ -525,7 +525,7 @@ The lines immediately before and after the separator must not be same comment pr TIP: Multi-document property files are often used in conjunction with activation properties such as `spring.config.activate.on-profile`. See the xref:features/external-config.adoc#features.external-config.files.activation-properties[next section] for details. -WARNING: Multi-document property files cannot be loaded by using the javadoc:org.springframework.context.annotation.PropertySource[format=annotation] or javadoc:org.springframework.test.context.TestPropertySource[format=annotation] annotations. +WARNING: Multi-document property files cannot be loaded by using the javadoc:org.springframework.context.annotation.PropertySource[format=annotation], javadoc:org.springframework.test.context.TestPropertySource[format=annotation] or javadoc:org.springframework.boot.test.context.TestYamlPropertySource[format=annotation] annotations. diff --git a/spring-boot-project/spring-boot-test/build.gradle b/spring-boot-project/spring-boot-test/build.gradle index dd73e9729a06..33b3697cab2d 100644 --- a/spring-boot-project/spring-boot-test/build.gradle +++ b/spring-boot-project/spring-boot-test/build.gradle @@ -58,5 +58,5 @@ dependencies { testImplementation("org.testng:testng") testRuntimeOnly("org.junit.vintage:junit-vintage-engine") + testRuntimeOnly("org.yaml:snakeyaml") } - diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySource.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySource.java new file mode 100644 index 000000000000..040f863eec12 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySource.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.context; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.TestPropertySource; + +/** + * {@code @TestYamlPropertySource} is an annotation that can be applied to a test class to + * configure the locations of YAML files and inlined properties to be added to the + * Environment's set of PropertySources for an ApplicationContext for integration tests. + *

+ * Provides a convenient alternative for + * {@code @TestPropertySource(locations = "...", factory = YamlPropertySourceFactory.class)}. + *

+ * {@code @TestYamlPropertySource} should be considered as {@code @TestPropertySource} but + * for YAML files. It intentionally does not support multi-document YAML files to maintain + * consistency with the behavior of {@code @TestPropertySource}. + * + * @author Dmytro Nosan + * @since 3.5.0 + * @see YamlPropertySourceFactory + * @see TestPropertySource + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@TestPropertySource(factory = YamlPropertySourceFactory.class) +@Repeatable(TestYamlPropertySources.class) +public @interface TestYamlPropertySource { + + /** + * Alias for {@link TestPropertySource#value()}. + * @return The resource locations of YAML files. + * @see TestPropertySource#value() for more details. + */ + @AliasFor(attribute = "value", annotation = TestPropertySource.class) + String[] value() default {}; + + /** + * Alias for {@link TestPropertySource#locations()}. + * @return The resource locations of YAML files. + * @see TestPropertySource#locations() for more details. + */ + @AliasFor(attribute = "locations", annotation = TestPropertySource.class) + String[] locations() default {}; + + /** + * Alias for {@link TestPropertySource#inheritLocations()}. + * @return Whether test property source {@link #locations} from superclasses and + * enclosing classes should be inherited. + * @see TestPropertySource#inheritLocations() for more details. + */ + @AliasFor(attribute = "inheritLocations", annotation = TestPropertySource.class) + boolean inheritLocations() default true; + + /** + * Alias for {@link TestPropertySource#properties()}. + * @return Inlined properties in the form of key-value pairs that + * should be added to the Environment + * @see TestPropertySource#properties() for more details. + */ + @AliasFor(attribute = "properties", annotation = TestPropertySource.class) + String[] properties() default {}; + + /** + * Alias for {@link TestPropertySource#inheritProperties()}. + * @return Whether inlined test {@link #properties} from superclasses and enclosing + * classes should be inherited. + * @see TestPropertySource#inheritProperties() for more details. + */ + @AliasFor(attribute = "inheritProperties", annotation = TestPropertySource.class) + boolean inheritProperties() default true; + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySources.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySources.java new file mode 100644 index 000000000000..6ef20035fb91 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/TestYamlPropertySources.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.context; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code @TestYamlPropertySources} is a container for one or more + * {@link TestYamlPropertySource @TestYamlPropertySource} declarations. + * + * @author Dmytro Nosan + * @since 3.5.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface TestYamlPropertySources { + + /** + * An array of one or more {@link TestYamlPropertySource @TestYamlPropertySource} + * declarations. + * @return {@link TestYamlPropertySource @TestYamlPropertySource} annotations. + */ + TestYamlPropertySource[] value(); + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/YamlPropertySourceFactory.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/YamlPropertySourceFactory.java new file mode 100644 index 000000000000..0579e5bf4ffa --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/YamlPropertySourceFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.context; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.core.io.support.PropertySourceFactory; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * An implementation of {@link PropertySourceFactory} that delegates the loading of + * {@code PropertySource} to {@link YamlPropertySourceLoader}. + *

+ * Even though {@link YamlPropertySourceLoader} supports multi-document YAML files, the + * {@code YamlPropertySourceFactory} intentionally does not allow this. + * + * @author Dmytro Nosan + * @since 3.5.0 + * @see TestYamlPropertySource + */ +public class YamlPropertySourceFactory implements PropertySourceFactory { + + private static final YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + + @Override + public PropertySource createPropertySource(String name, EncodedResource encodedResource) throws IOException { + Resource resource = encodedResource.getResource(); + String propertySourceName = getPropertySourceName(name, resource); + List> propertySources = loader.load(propertySourceName, resource); + Assert.isTrue(propertySources.size() <= 1, () -> resource + " is a multi-document YAML file"); + if (propertySources.isEmpty()) { + return new MapPropertySource(name, Collections.emptyMap()); + } + return propertySources.get(0); + } + + private static String getPropertySourceName(String name, Resource resource) { + if (StringUtils.hasText(name)) { + return name; + } + String description = resource.getDescription(); + if (StringUtils.hasText(description)) { + return description; + } + return resource.getClass().getSimpleName() + "@" + System.identityHashCode(resource); + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/TestYamlPropertySourceIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/TestYamlPropertySourceIntegrationTests.java new file mode 100644 index 000000000000..e8adae6a5bb6 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/TestYamlPropertySourceIntegrationTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.context; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link YamlPropertySourceFactory} with + * {@link TestYamlPropertySource}. + * + * @author Dmytro Nosan + */ +@SpringJUnitConfig +@TestYamlPropertySource({ "test.yaml", "test1.yaml" }) +@TestYamlPropertySource(locations = "test2.yaml", properties = "key:value") +class TestYamlPropertySourceIntegrationTests { + + @Autowired + private Environment environment; + + @Test + void loadProperties() { + assertThat(this.environment.getProperty("spring.bar")).isEqualTo("bar"); + assertThat(this.environment.getProperty("spring.foo")).isEqualTo("baz"); + assertThat(this.environment.getProperty("spring.buzz")).isEqualTo("fazz"); + assertThat(this.environment.getProperty("spring.boot")).isEqualTo("boot"); + assertThat(this.environment.getProperty("key")).isEqualTo("value"); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/YamlPropertySourceFactoryTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/YamlPropertySourceFactoryTests.java new file mode 100644 index 000000000000..06479cd4ca1d --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/YamlPropertySourceFactoryTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.context; + +import java.io.IOException; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.EncodedResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link YamlPropertySourceFactory}. + * + * @author Dmytro Nosan + */ +class YamlPropertySourceFactoryTests { + + private final YamlPropertySourceFactory factory = new YamlPropertySourceFactory(); + + @Test + void shouldCreatePropertySourceWithGivenName() throws IOException { + EncodedResource resource = new EncodedResource(create("test.yaml")); + PropertySource propertySource = this.factory.createPropertySource("test", resource); + assertThat(propertySource.getName()).isEqualTo("test"); + assertProperties(propertySource); + } + + @Test + void shouldCreatePropertySourceWithResourceDescriptionName() throws IOException { + EncodedResource resource = new EncodedResource(create("test.yaml")); + PropertySource propertySource = this.factory.createPropertySource(null, resource); + assertThat(propertySource.getName()).isEqualTo(resource.getResource().getDescription()); + assertProperties(propertySource); + } + + @Test + void shouldCreatePropertySourceWithGeneratedName() throws IOException { + Resource resource = spy(create("test.yaml")); + willReturn(null).given(resource).getDescription(); + PropertySource propertySource = this.factory.createPropertySource(null, new EncodedResource(resource)); + assertThat(propertySource.getName()).startsWith("ClassPathResource@"); + assertProperties(propertySource); + } + + @Test + void shouldNotCreatePropertySourceWhenMultiDocumentYaml() { + EncodedResource resource = new EncodedResource(create("multi.yaml")); + assertThatIllegalArgumentException().isThrownBy(() -> this.factory.createPropertySource(null, resource)) + .withMessageContaining("is a multi-document YAML file"); + } + + @Test + void shouldCreateEmptyPropertySourceWhenYamlFileIsEmpty() throws IOException { + EncodedResource resource = new EncodedResource(create("empty.yaml")); + PropertySource propertySource = this.factory.createPropertySource("empty", resource); + assertThat(propertySource.getName()).isEqualTo("empty"); + assertThat(propertySource.getSource()).asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class)) + .isEmpty(); + } + + private Resource create(String name) { + return new ClassPathResource(name, getClass()); + } + + private static void assertProperties(PropertySource propertySource) { + assertThat(propertySource.getProperty("spring.bar")).isEqualTo("bar"); + assertThat(propertySource.getProperty("spring.foo")).isEqualTo("baz"); + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/empty.yaml b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/empty.yaml new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/multi.yaml b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/multi.yaml new file mode 100644 index 000000000000..00a8f63a308e --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/multi.yaml @@ -0,0 +1,6 @@ +spring: + bar: bar + foo: baz +--- +spring: + foo: baz diff --git a/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test.yaml b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test.yaml new file mode 100644 index 000000000000..fcda0bae3298 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test.yaml @@ -0,0 +1,3 @@ +spring: + bar: bar + foo: baz diff --git a/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test1.yaml b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test1.yaml new file mode 100644 index 000000000000..4b1223247cf5 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test1.yaml @@ -0,0 +1,2 @@ +spring: + buzz: fazz diff --git a/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test2.yaml b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test2.yaml new file mode 100644 index 000000000000..ccf7847ab173 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/resources/org/springframework/boot/test/context/test2.yaml @@ -0,0 +1,2 @@ +spring: + boot: boot