diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc index e9f65a1752..7d59fd76a6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc @@ -1062,6 +1062,26 @@ NOTE: Using a `@ServiceConnection` is recommended whenever possible, however, dy +[[features.testing.testcontainers.at-development-time.importing-container-declarations]] +===== Importing Testcontainer Declaration Classes +A common pattern when using Testcontainers is to declare `Container` instances as static fields. +Often these fields are defined directly on the test class. +They can also be declared on a parent class or on an interface that the test implements. + +For example, the following `MyContainers` interface declares `mongo` and `neo4j` containers: + +include::code:MyContainers[] + +If you already have containers defined in this way, or you just prefer this style, you can import these declaration classes rather than defining you containers as `@Bean` methods. +To do so, add the `@ImportTestcontainers` annotation to your test configuration class: + +include::code:MyContainersConfiguration[] + +TIP: You can use the `@ServiceConnection` annotation on `Container` fields to establish service connections. +You can also add <> to your declaration class. + + + [[features.testing.utilities]] === Test Utilities A few test utility classes that are generally useful when testing your application are packaged as part of `spring-boot`. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java new file mode 100644 index 0000000000..dd7ea84a9c --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2023 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.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations; + +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.containers.Neo4jContainer; +import org.testcontainers.junit.jupiter.Container; + +public interface MyContainers { + + @Container + MongoDBContainer monogContainer = new MongoDBContainer("mongo:5.0"); + + @Container + Neo4jContainer neo4jContainer = new Neo4jContainer<>("neo4j:5"); + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java new file mode 100644 index 0000000000..00b4078610 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2023 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.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; + +@TestConfiguration(proxyBeanMethods = false) +@ImportTestcontainers(MyContainers.class) +public class MyContainersConfiguration { + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt new file mode 100644 index 0000000000..89c5b8ecc2 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2023 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.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.context.ImportTestcontainers + +@TestConfiguration(proxyBeanMethods = false) +@ImportTestcontainers(MyContainers::class) +class MyContainersConfiguration { + +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/TestcontainerBeanDefinition.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/TestcontainerBeanDefinition.java new file mode 100644 index 0000000000..4449b11a5f --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/TestcontainerBeanDefinition.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2023 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.testcontainers.beans; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.annotation.MergedAnnotations; + +/** + * Extended {@link org.springframework.beans.factory.config.BeanDefinition} interface used + * to register testcontainer beans. + * + * @author Phillip Webb + * @since 3.1.0 + */ +public interface TestcontainerBeanDefinition extends BeanDefinition { + + /** + * Return the container image name or {@code null} if the image name is not yet known. + * @return the container image name + */ + String getContainerImageName(); + + /** + * Return any annotations declared alongside the container. + * @return annotations declared with the container + */ + MergedAnnotations getAnnotations(); + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/package-info.java new file mode 100644 index 0000000000..7cbfb5f547 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/beans/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Spring bean support classes for Testcontainers. + */ +package org.springframework.boot.testcontainers.beans; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java new file mode 100644 index 0000000000..f943092f8c --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2023 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.testcontainers.context; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import org.testcontainers.containers.Container; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Used by {@link ImportTestcontainersRegistrar} to import {@link Container} fields. + * + * @author Phillip Webb + */ +class ContainerFieldsImporter { + + void registerBeanDefinitions(BeanDefinitionRegistry registry, BeanNameGenerator importBeanNameGenerator, + Class definitionClass) { + for (Field field : getContainerFields(definitionClass)) { + assertValid(field); + Container container = getContainer(field); + registerBeanDefinition(registry, importBeanNameGenerator, field, container); + } + } + + private List getContainerFields(Class containersClass) { + List containerFields = new ArrayList<>(); + ReflectionUtils.doWithFields(containersClass, containerFields::add, this::isContainerField); + return List.copyOf(containerFields); + } + + private boolean isContainerField(Field candidate) { + return Container.class.isAssignableFrom(candidate.getType()); + } + + private void assertValid(Field field) { + Assert.state(Modifier.isStatic(field.getModifiers()), + () -> "Container field '" + field.getName() + "' must be static"); + } + + private Container getContainer(Field field) { + ReflectionUtils.makeAccessible(field); + Container container = (Container) ReflectionUtils.getField(field, null); + Assert.state(container != null, () -> "Container field '" + field.getName() + "' must not have a null value"); + return container; + } + + private void registerBeanDefinition(BeanDefinitionRegistry registry, BeanNameGenerator importBeanNameGenerator, + Field field, Container container) { + TestcontainerFieldBeanDefinition beanDefinition = new TestcontainerFieldBeanDefinition(field, container); + String beanName = importBeanNameGenerator.generateBeanName(beanDefinition, registry); + registry.registerBeanDefinition(beanName, beanDefinition); + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java new file mode 100644 index 0000000000..35d72d7c72 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2023 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.testcontainers.context; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Set; + +import org.springframework.boot.testcontainers.properties.TestcontainersPropertySource; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.env.Environment; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Used by {@link ImportTestcontainersRegistrar} to import + * {@link DynamicPropertySource @DynamicPropertySource} methods. + * + * @author Phillip Webb + */ +class DynamicPropertySourceMethodsImporter { + + private final Environment environment; + + DynamicPropertySourceMethodsImporter(Environment environment) { + this.environment = environment; + } + + void registerDynamicPropertySources(Class definitionClass) { + Set methods = MethodIntrospector.selectMethods(definitionClass, this::isAnnotated); + if (methods.isEmpty()) { + return; + } + DynamicPropertyRegistry registry = TestcontainersPropertySource.attach(this.environment); + methods.forEach((method) -> { + assertValid(method); + ReflectionUtils.makeAccessible(method); + ReflectionUtils.invokeMethod(method, null, registry); + }); + } + + private boolean isAnnotated(Method method) { + return MergedAnnotations.from(method).isPresent(DynamicPropertySource.class); + } + + private void assertValid(Method method) { + Assert.state(Modifier.isStatic(method.getModifiers()), + () -> "@DynamicPropertySource method '" + method.getName() + "' must be static"); + Class[] types = method.getParameterTypes(); + Assert.state(types.length == 1 && types[0] == DynamicPropertyRegistry.class, + () -> "@DynamicPropertySource method '" + method.getName() + + "' must accept a single DynamicPropertyRegistry argument"); + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainers.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainers.java new file mode 100644 index 0000000000..5f99743017 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainers.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2023 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.testcontainers.context; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.testcontainers.containers.Container; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; + +/** + * Imports idiomatic Testcontainer declaration classes into the Spring + * {@link ApplicationContext}. The following elements will be considered from the imported + * classes: + *
    + *
  • All static fields that declare {@link Container} values.
  • + *
  • All {@code @DynamicPropertySource} annotated methods.
  • + *
+ * + * @author Phillip Webb + * @since 3.1.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(ImportTestcontainersRegistrar.class) +public @interface ImportTestcontainers { + + /** + * The declaration classes to import. If no {@code value} is defined then the class + * that declares the {@link ImportTestcontainers @ImportTestcontainers} annotation + * will be searched. + * @return the definition classes to import + */ + Class[] value() default {}; + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java new file mode 100644 index 0000000000..61d3373d7a --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2023 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.testcontainers.context; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; + +/** + * {@link ImportBeanDefinitionRegistrar} for + * {@link ImportTestcontainers @ImportTestcontainers}. + * + * @author Phillip Webb + * @see ContainerFieldsImporter + * @see DynamicPropertySourceMethodsImporter + */ +class ImportTestcontainersRegistrar implements ImportBeanDefinitionRegistrar { + + private static final String DYNAMIC_PROPERTY_SOURCE_CLASS = "org.springframework.test.context.DynamicPropertySource"; + + private final ContainerFieldsImporter containerFieldsImporter; + + private final DynamicPropertySourceMethodsImporter dynamicPropertySourceMethodsImporter; + + ImportTestcontainersRegistrar(Environment environment) { + this.containerFieldsImporter = new ContainerFieldsImporter(); + this.dynamicPropertySourceMethodsImporter = (!ClassUtils.isPresent(DYNAMIC_PROPERTY_SOURCE_CLASS, null)) ? null + : new DynamicPropertySourceMethodsImporter(environment); + } + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry, + BeanNameGenerator importBeanNameGenerator) { + MergedAnnotation annotation = importingClassMetadata.getAnnotations() + .get(ImportTestcontainers.class); + Class[] definitionClasses = annotation.getClassArray(MergedAnnotation.VALUE); + if (ObjectUtils.isEmpty(definitionClasses)) { + Class importingClass = ClassUtils.resolveClassName(importingClassMetadata.getClassName(), null); + definitionClasses = new Class[] { importingClass }; + } + registerBeanDefinitions(registry, importBeanNameGenerator, definitionClasses); + } + + private void registerBeanDefinitions(BeanDefinitionRegistry registry, BeanNameGenerator importBeanNameGenerator, + Class[] definitionClasses) { + for (Class definitionClass : definitionClasses) { + this.containerFieldsImporter.registerBeanDefinitions(registry, importBeanNameGenerator, definitionClass); + if (this.dynamicPropertySourceMethodsImporter != null) { + this.dynamicPropertySourceMethodsImporter.registerDynamicPropertySources(definitionClass); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java new file mode 100644 index 0000000000..a6b199b96b --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2023 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.testcontainers.context; + +import java.lang.reflect.Field; + +import org.testcontainers.containers.Container; + +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; +import org.springframework.core.annotation.MergedAnnotations; + +/** + * {@link RootBeanDefinition} used for testcontainer bean definitions. + * + * @author Phillip Webb + */ +class TestcontainerFieldBeanDefinition extends RootBeanDefinition implements TestcontainerBeanDefinition { + + private final Container container; + + private final MergedAnnotations annotations; + + TestcontainerFieldBeanDefinition(Field field, Container container) { + this.container = container; + this.annotations = MergedAnnotations.from(field); + this.setBeanClass(container.getClass()); + setInstanceSupplier(() -> container); + } + + @Override + public String getContainerImageName() { + return this.container.getDockerImageName(); + } + + @Override + public MergedAnnotations getAnnotations() { + return this.annotations; + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/package-info.java new file mode 100644 index 0000000000..a176f95122 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Spring context support classes for Testcontainers. + */ +package org.springframework.boot.testcontainers.context; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/package-info.java new file mode 100644 index 0000000000..19d2db1871 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Support for testcontainers. + */ +package org.springframework.boot.testcontainers; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java index 4e82bc36f6..f03ed2960d 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationRegistrar.java @@ -16,6 +16,7 @@ package org.springframework.boot.testcontainers.service.connection; +import java.util.LinkedHashSet; import java.util.Set; import org.testcontainers.containers.Container; @@ -27,7 +28,9 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; import org.springframework.boot.origin.Origin; +import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.type.AnnotationMetadata; /** @@ -56,15 +59,24 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio new ConnectionDetailsFactories()); for (String beanName : beanFactory.getBeanNamesForType(Container.class)) { BeanDefinition beanDefinition = getBeanDefinition(beanFactory, beanName); - for (ServiceConnection annotation : getAnnotations(beanFactory, beanName)) { + for (ServiceConnection annotation : getAnnotations(beanFactory, beanName, beanDefinition)) { ContainerConnectionSource source = createSource(beanFactory, beanName, beanDefinition, annotation); registrar.registerBeanDefinitions(registry, source); } } } - private Set getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName) { - return beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false); + private Set getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName, + BeanDefinition beanDefinition) { + Set annoations = new LinkedHashSet<>(); + annoations.addAll(beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false)); + if (beanDefinition instanceof TestcontainerBeanDefinition testcontainerBeanDefinition) { + testcontainerBeanDefinition.getAnnotations() + .stream(ServiceConnection.class) + .map(MergedAnnotation::synthesize) + .forEach(annoations::add); + } + return annoations; } private BeanDefinition getBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) { @@ -82,7 +94,9 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio ServiceConnection annotation) { Origin origin = new BeanOrigin(beanName, beanDefinition); Class containerType = (Class) beanFactory.getType(beanName, false); - return new ContainerConnectionSource<>(beanName, origin, containerType, null, annotation, + String containerImageName = (beanDefinition instanceof TestcontainerBeanDefinition testcontainerBeanDefinition) + ? testcontainerBeanDefinition.getContainerImageName() : null; + return new ContainerConnectionSource<>(beanName, origin, containerType, containerImageName, annotation, () -> beanFactory.getBean(beanName, containerType)); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 3e0cefae44..5d4e2eb5ca 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,2 @@ -org.springframework.boot.testcontainers.properties.DynamicProperySourceAutoConfiguration +org.springframework.boot.testcontainers.properties.TestcontainersPropertySourceAutoConfiguration org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java new file mode 100644 index 0000000000..4c99450b4c --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2012-2023 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.testcontainers; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.PostgreSQLContainer; + +import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ImportTestcontainers}. + * + * @author Phillip Webb + */ +class ImportTestcontainersTests { + + private AnnotationConfigApplicationContext applicationContext; + + @AfterEach + void teardown() { + if (this.applicationContext != null) { + this.applicationContext.close(); + } + } + + @Test + void importWithoutValueRegistersBeans() { + this.applicationContext = new AnnotationConfigApplicationContext(ImportWithoutValue.class); + String[] beanNames = this.applicationContext.getBeanNamesForType(PostgreSQLContainer.class); + assertThat(beanNames).hasSize(1); + assertThat(this.applicationContext.getBean(beanNames[0])).isSameAs(ImportWithoutValue.container); + TestcontainerBeanDefinition beanDefinition = (TestcontainerBeanDefinition) this.applicationContext + .getBeanDefinition(beanNames[0]); + assertThat(beanDefinition.getContainerImageName()).isEqualTo(ImportWithoutValue.container.getDockerImageName()); + assertThat(beanDefinition.getAnnotations().isPresent(ContainerAnnotation.class)).isTrue(); + } + + @Test + void importWithValueRegistersBeans() { + this.applicationContext = new AnnotationConfigApplicationContext(ImportWithValue.class); + String[] beanNames = this.applicationContext.getBeanNamesForType(PostgreSQLContainer.class); + assertThat(beanNames).hasSize(1); + assertThat(this.applicationContext.getBean(beanNames[0])).isSameAs(ContainerDefinitions.container); + TestcontainerBeanDefinition beanDefinition = (TestcontainerBeanDefinition) this.applicationContext + .getBeanDefinition(beanNames[0]); + assertThat(beanDefinition.getContainerImageName()) + .isEqualTo(ContainerDefinitions.container.getDockerImageName()); + assertThat(beanDefinition.getAnnotations().isPresent(ContainerAnnotation.class)).isTrue(); + } + + @Test + void importWhenHasNoContainerFieldsDoesNothing() { + this.applicationContext = new AnnotationConfigApplicationContext(NoContainers.class); + String[] beanNames = this.applicationContext.getBeanNamesForType(Container.class); + assertThat(beanNames).isEmpty(); + } + + @Test + void importWhenHasNullContainerFieldThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> this.applicationContext = new AnnotationConfigApplicationContext(NullContainer.class)) + .withMessage("Container field 'container' must not have a null value"); + } + + @Test + void importWhenHasNonStaticContainerFieldThrowsException() { + assertThatIllegalStateException() + .isThrownBy( + () -> this.applicationContext = new AnnotationConfigApplicationContext(NonStaticContainer.class)) + .withMessage("Container field 'container' must be static"); + } + + @Test + void importWhenHasContainerDefinitionsWithDynamicPropertySource() { + this.applicationContext = new AnnotationConfigApplicationContext( + ContainerDefinitionsWithDynamicPropertySource.class); + assertThat(this.applicationContext.getEnvironment().containsProperty("container.port")).isTrue(); + } + + @Test + void importWhenHasNonStaticDynamicPropertySourceMethod() { + assertThatIllegalStateException() + .isThrownBy(() -> this.applicationContext = new AnnotationConfigApplicationContext( + NonStaticDynamicPropertySourceMethod.class)) + .withMessage("@DynamicPropertySource method 'containerProperties' must be static"); + } + + @Test + void importWhenHasBadArgsDynamicPropertySourceMethod() { + assertThatIllegalStateException() + .isThrownBy(() -> this.applicationContext = new AnnotationConfigApplicationContext( + BadArgsDynamicPropertySourceMethod.class)) + .withMessage("@DynamicPropertySource method 'containerProperties' must be static"); + } + + @ImportTestcontainers + static class ImportWithoutValue { + + @ContainerAnnotation + static PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + } + + @ImportTestcontainers(ContainerDefinitions.class) + static class ImportWithValue { + + } + + @ImportTestcontainers + static class NoContainers { + + } + + @ImportTestcontainers + static class NullContainer { + + static PostgreSQLContainer container = null; + + } + + @ImportTestcontainers + static class NonStaticContainer { + + PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + } + + interface ContainerDefinitions { + + @ContainerAnnotation + PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + } + + @Retention(RetentionPolicy.RUNTIME) + static @interface ContainerAnnotation { + + } + + @ImportTestcontainers + static class ContainerDefinitionsWithDynamicPropertySource { + + static PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + @DynamicPropertySource + static void containerProperties(DynamicPropertyRegistry registry) { + registry.add("container.port", container::getFirstMappedPort); + } + + } + + @ImportTestcontainers + static class NonStaticDynamicPropertySourceMethod { + + @DynamicPropertySource + void containerProperties(DynamicPropertyRegistry registry) { + } + + } + + @ImportTestcontainers + static class BadArgsDynamicPropertySourceMethod { + + @DynamicPropertySource + void containerProperties() { + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java index 9afc86a2f2..a55163bd1e 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionAutoConfigurationTests.java @@ -16,18 +16,29 @@ package org.springframework.boot.testcontainers.service.connection; +import java.util.Set; + import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisConnectionDetails; +import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; import org.springframework.boot.testcontainers.lifecycle.TestcontainersLifecycleApplicationContextInitializer; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; import org.springframework.boot.testsupport.testcontainers.RedisContainer; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.type.AnnotationMetadata; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -78,6 +89,18 @@ class ServiceConnectionAutoConfigurationTests { } } + @Test + void whenHasTestcontainersBeanDefinition() { + try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) { + applicationContext.register(WithNoExtraAutoConfiguration.class, + TestcontainerBeanDefinitionConfiguration.class); + new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); + applicationContext.refresh(); + RedisConnectionDetails connectionDetails = applicationContext.getBean(RedisConnectionDetails.class); + assertThat(connectionDetails.getClass().getName()).isEqualTo(REDIS_CONTAINER_CONNECTION_DETAILS); + } + } + @Configuration(proxyBeanMethods = false) @ImportAutoConfiguration(ServiceConnectionAutoConfiguration.class) static class WithNoExtraAutoConfiguration { @@ -111,4 +134,42 @@ class ServiceConnectionAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + @Import(TestcontainerBeanDefinitionRegistrar.class) + static class TestcontainerBeanDefinitionConfiguration { + + } + + static class TestcontainerBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry, + BeanNameGenerator importBeanNameGenerator) { + registry.registerBeanDefinition("redisContainer", new TestcontainersRootBeanDefinition()); + } + + } + + static class TestcontainersRootBeanDefinition extends RootBeanDefinition implements TestcontainerBeanDefinition { + + private final RedisContainer container = new RedisContainer(); + + TestcontainersRootBeanDefinition() { + setBeanClass(RedisContainer.class); + setInstanceSupplier(() -> this.container); + } + + @Override + public String getContainerImageName() { + return this.container.getDockerImageName(); + } + + @Override + public MergedAnnotations getAnnotations() { + MergedAnnotation annotation = MergedAnnotation.of(ServiceConnection.class); + return MergedAnnotations.of(Set.of(annotation)); + } + + } + } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestPropertiesImportSampleSessionRedisApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestPropertiesImportSampleSessionRedisApplication.java new file mode 100644 index 0000000000..ab73d34ee6 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestPropertiesImportSampleSessionRedisApplication.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2023 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 smoketest.session.redis; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testsupport.testcontainers.RedisContainer; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +public class TestPropertiesImportSampleSessionRedisApplication { + + public static void main(String[] args) { + SpringApplication.from(SampleSessionRedisApplication::main).with(ContainerConfiguration.class).run(args); + } + + @ImportTestcontainers + static class ContainerConfiguration { + + static RedisContainer container = new RedisContainer(); + + @DynamicPropertySource + static void containerProperties(DynamicPropertyRegistry properties) { + properties.add("spring.data.redis.host", container::getHost); + properties.add("spring.data.redis.port", container::getFirstMappedPort); + } + + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestServiceConnectionImportSampleSessionRedisApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestServiceConnectionImportSampleSessionRedisApplication.java new file mode 100644 index 0000000000..c20303b006 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-session-redis/src/test/java/smoketest/session/redis/TestServiceConnectionImportSampleSessionRedisApplication.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2023 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 smoketest.session.redis; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.RedisContainer; + +public class TestServiceConnectionImportSampleSessionRedisApplication { + + public static void main(String[] args) { + SpringApplication.from(SampleSessionRedisApplication::main).with(ContainerConfiguration.class).run(args); + } + + @ImportTestcontainers + static class ContainerConfiguration { + + @ServiceConnection // We don't need a name here because we have the container + static RedisContainer redisContainer = new RedisContainer(); + + } + +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index ea6991eab4..dd7c500761 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -75,4 +75,6 @@ + +