From 8628f7334fdf67ac54d79a57a6c4bdb44e6f68d4 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 27 Jun 2024 17:25:36 -0700 Subject: [PATCH] Ensure `@AutoConfigureTestDatabase` does not replace test databases Update `@AutoConfigureTestDatabase` support so that by default test databases are not replaced. Fixes gh-35253 --- .../build/architecture/ArchitectureCheck.java | 14 ++- .../container/ContainerImageMetadata.java | 66 +++++++++++ .../autoconfigure/container/package-info.java | 20 ++++ .../ContainerImageMetadataTests.java | 82 +++++++++++++ ...ServiceConnectionsApplicationListener.java | 8 +- .../build.gradle | 5 + ...DatabaseDockerComposeIntegrationTests.java | 95 +++++++++++++++ ...DynamicPropertySourceIntegrationTests.java | 74 ++++++++++++ ...tabaseNonTestDatabaseIntegrationTests.java | 82 +++++++++++++ ...baseServiceConnectionIntegrationTests.java | 70 +++++++++++ .../resources/postgres-compose.yaml | 9 ++ .../jdbc/AutoConfigureTestDatabase.java | 21 +++- .../jdbc/TestDatabaseAutoConfiguration.java | 112 ++++++++++++++++-- .../context/ContainerFieldsImporter.java | 5 +- .../ConnectionDetailsRegistrar.java | 5 +- .../connection/ContainerConnectionSource.java | 8 ++ 16 files changed, 654 insertions(+), 22 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadata.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadataTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDockerComposeIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDynamicPropertySourceIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseServiceConnectionIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/resources/postgres-compose.yaml diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java index 0131943efb..b41c573dd9 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java @@ -168,21 +168,23 @@ public abstract class ArchitectureCheck extends DefaultTask { .and() .haveRawReturnType( Predicates.assignableTo("org.springframework.beans.factory.config.BeanFactoryPostProcessor")) - .should(haveNoParameters()) + .should(onlyInjectEnvironment()) .andShould() .beStatic() .allowEmptyShould(true); } - private ArchCondition haveNoParameters() { - return new ArchCondition<>("have no parameters") { + private ArchCondition onlyInjectEnvironment() { + return new ArchCondition<>("only inject Environment") { @Override public void check(JavaMethod item, ConditionEvents events) { List parameters = item.getParameters(); - if (!parameters.isEmpty()) { - events - .add(SimpleConditionEvent.violated(item, item.getDescription() + " should have no parameters")); + for (JavaParameter parameter : parameters) { + if (!"org.springframework.core.env.Environment".equals(parameter.getType().getName())) { + events.add(SimpleConditionEvent.violated(item, + item.getDescription() + " should only inject Environment")); + } } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadata.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadata.java new file mode 100644 index 0000000000..9a92d9c58c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadata.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2024 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.autoconfigure.container; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.core.AttributeAccessor; + +/** + * Metadata about a container image that can be added to an {@link AttributeAccessor}. + * Primarily designed to be attached to {@link BeanDefinition BeanDefinitions} created in + * support of Testcontainers or Docker Compose. + * + * @param imageName the contaimer image name or {@code null} if the image name is not yet + * known + * @author Phillip Webb + * @since 3.4.0 + */ +public record ContainerImageMetadata(String imageName) { + + static final String NAME = ContainerImageMetadata.class.getName(); + + /** + * Add this container image metadata to the given attributes. + * @param attributes the attributes to add the metadata to + */ + public void addTo(AttributeAccessor attributes) { + if (attributes != null) { + attributes.setAttribute(NAME, this); + } + } + + /** + * Return {@code true} if {@link ContainerImageMetadata} has been added to the given + * attributes. + * @param attributes the attributes to check + * @return if metadata is present + */ + public static boolean isPresent(AttributeAccessor attributes) { + return getFrom(attributes) != null; + } + + /** + * Return {@link ContainerImageMetadata} from the given attributes or {@code null} if + * no metadata has been added. + * @param attributes the attributes + * @return the metadata or {@code null} + */ + public static ContainerImageMetadata getFrom(AttributeAccessor attributes) { + return (attributes != null) ? (ContainerImageMetadata) attributes.getAttribute(NAME) : null; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/package-info.java new file mode 100644 index 0000000000..0dda84287a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/container/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2024 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 classes related to auto-configuration involving containers. + */ +package org.springframework.boot.autoconfigure.container; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadataTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadataTests.java new file mode 100644 index 0000000000..28822d8dac --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/container/ContainerImageMetadataTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2024 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.autoconfigure.container; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.AttributeAccessor; +import org.springframework.core.AttributeAccessorSupport; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ContainerImageMetadata}. + * + * @author Phillip Webb + */ +class ContainerImageMetadataTests { + + private ContainerImageMetadata metadata = new ContainerImageMetadata("test"); + + private AttributeAccessor attributes = new AttributeAccessorSupport() { + + }; + + @Test + void addToWhenAttributesIsNullDoesNothing() { + this.metadata.addTo(null); + } + + @Test + void addToAddsMetadata() { + this.metadata.addTo(this.attributes); + assertThat(this.attributes.getAttribute(ContainerImageMetadata.NAME)).isSameAs(this.metadata); + } + + @Test + void isPresentWhenPresentReturnsTrue() { + this.metadata.addTo(this.attributes); + assertThat(ContainerImageMetadata.isPresent(this.attributes)).isTrue(); + } + + @Test + void isPresentWhenNotPresentReturnsFalse() { + assertThat(ContainerImageMetadata.isPresent(this.attributes)).isFalse(); + } + + @Test + void isPresentWhenNullAttributesReturnsFalse() { + assertThat(ContainerImageMetadata.isPresent(null)).isFalse(); + } + + @Test + void getFromWhenPresentReturnsMetadata() { + this.metadata.addTo(this.attributes); + assertThat(ContainerImageMetadata.getFrom(this.attributes)).isSameAs(this.metadata); + } + + @Test + void getFromWhenNotPresentReturnsNull() { + assertThat(ContainerImageMetadata.getFrom(this.attributes)).isNull(); + } + + @Test + void getFromWhenNullAttributesReturnsNull() { + assertThat(ContainerImageMetadata.getFrom(null)).isNull(); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeServiceConnectionsApplicationListener.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeServiceConnectionsApplicationListener.java index 03efb04de0..4cf5135d26 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeServiceConnectionsApplicationListener.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeServiceConnectionsApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.autoconfigure.container.ContainerImageMetadata; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; import org.springframework.boot.docker.compose.core.RunningService; @@ -77,10 +78,13 @@ class DockerComposeServiceConnectionsApplicationListener @SuppressWarnings("unchecked") private void register(BeanDefinitionRegistry registry, RunningService runningService, Class connectionDetailsType, ConnectionDetails connectionDetails) { + ContainerImageMetadata containerMetadata = new ContainerImageMetadata(runningService.image().toString()); String beanName = getBeanName(runningService, connectionDetailsType); Class beanType = (Class) connectionDetails.getClass(); Supplier beanSupplier = () -> (T) connectionDetails; - registry.registerBeanDefinition(beanName, new RootBeanDefinition(beanType, beanSupplier)); + RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType, beanSupplier); + containerMetadata.addTo(beanDefinition); + registry.registerBeanDefinition(beanName, beanDefinition); } private String getBeanName(RunningService runningService, Class connectionDetailsType) { diff --git a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle index 2c37870ce1..1c8684fe2a 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle @@ -13,18 +13,23 @@ dependencies { api(project(":spring-boot-project:spring-boot-test")) api(project(":spring-boot-project:spring-boot-autoconfigure")) + dockerTestImplementation(project(":spring-boot-project:spring-boot-docker-compose")) dockerTestImplementation(project(":spring-boot-project:spring-boot-testcontainers")) dockerTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support-docker")) + dockerTestImplementation("com.zaxxer:HikariCP") dockerTestImplementation("io.projectreactor:reactor-test") dockerTestImplementation("com.redis:testcontainers-redis") + dockerTestImplementation("com.h2database:h2") dockerTestImplementation("org.assertj:assertj-core") dockerTestImplementation("org.junit.jupiter:junit-jupiter") + dockerTestImplementation("org.postgresql:postgresql") dockerTestImplementation("org.testcontainers:cassandra") dockerTestImplementation("org.testcontainers:couchbase") dockerTestImplementation("org.testcontainers:elasticsearch") dockerTestImplementation("org.testcontainers:junit-jupiter") dockerTestImplementation("org.testcontainers:mongodb") dockerTestImplementation("org.testcontainers:neo4j") + dockerTestImplementation("org.testcontainers:postgresql") dockerTestImplementation("org.testcontainers:testcontainers") dockerTestRuntimeOnly("io.lettuce:lettuce-core") diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDockerComposeIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDockerComposeIntegrationTests.java new file mode 100644 index 0000000000..79a23e4163 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDockerComposeIntegrationTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2024 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.autoconfigure.jdbc; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabaseDockerComposeIntegrationTests.SetupDockerCompose; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.container.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.support.TestPropertySourceUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link AutoConfigureTestDatabase} with Docker Compose. + * + * @author Phillip Webb + */ +@SpringBootTest +@ContextConfiguration(initializers = SetupDockerCompose.class) +@AutoConfigureTestDatabase +@OverrideAutoConfiguration(enabled = false) +@DisabledIfDockerUnavailable +class AutoConfigureTestDatabaseDockerComposeIntegrationTests { + + @Autowired + private DataSource dataSource; + + @Test + void dataSourceIsNotReplaced() { + assertThat(this.dataSource).isInstanceOf(HikariDataSource.class).isNotInstanceOf(EmbeddedDatabase.class); + } + + @Configuration + @ImportAutoConfiguration(DataSourceAutoConfiguration.class) + static class Config { + + } + + static class SetupDockerCompose implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + try { + Path composeFile = Files.createTempFile("", "-postgres-compose"); + String composeFileContent = new ClassPathResource("postgres-compose.yaml") + .getContentAsString(StandardCharsets.UTF_8) + .replace("{imageName}", TestImage.POSTGRESQL.toString()); + Files.writeString(composeFile, composeFileContent); + TestPropertySourceUtils.addInlinedPropertiesToEnvironment(applicationContext, + "spring.docker.compose.skip.in-tests=false", "spring.docker.compose.stop.command=down", + "spring.docker.compose.file=" + composeFile.toAbsolutePath().toString()); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDynamicPropertySourceIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDynamicPropertySourceIntegrationTests.java new file mode 100644 index 0000000000..c18f27702c --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseDynamicPropertySourceIntegrationTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2024 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.autoconfigure.jdbc; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link AutoConfigureTestDatabase} with Testcontainers and a + * {@link DynamicPropertySource @DynamicPropertySource}. + * + * @author Phillip Webb + */ +@SpringBootTest +@AutoConfigureTestDatabase +@Testcontainers(disabledWithoutDocker = true) +@OverrideAutoConfiguration(enabled = false) +class AutoConfigureTestDatabaseDynamicPropertySourceIntegrationTests { + + @Container + static PostgreSQLContainer postgres = TestImage.container(PostgreSQLContainer.class); + + @Autowired + private DataSource dataSource; + + @DynamicPropertySource + static void jdbcProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + } + + @Test + void dataSourceIsNotReplaced() { + assertThat(this.dataSource).isInstanceOf(HikariDataSource.class).isNotInstanceOf(EmbeddedDatabase.class); + } + + @Configuration + @ImportAutoConfiguration(DataSourceAutoConfiguration.class) + static class Config { + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests.java new file mode 100644 index 0000000000..463d47159d --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2024 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.autoconfigure.jdbc; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests.SetupDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.container.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.support.TestPropertySourceUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link AutoConfigureTestDatabase} with Docker Compose. + * + * @author Phillip Webb + */ +@SpringBootTest +@ContextConfiguration(initializers = SetupDatabase.class) +@AutoConfigureTestDatabase +@OverrideAutoConfiguration(enabled = false) +@DisabledIfDockerUnavailable +class AutoConfigureTestDatabaseNonTestDatabaseIntegrationTests { + + @Container + static PostgreSQLContainer postgres = TestImage.container(PostgreSQLContainer.class); + + @Autowired + private DataSource dataSource; + + @Test + void dataSourceIsReplaced() { + assertThat(this.dataSource).isInstanceOf(EmbeddedDatabase.class); + } + + @Configuration + @ImportAutoConfiguration(DataSourceAutoConfiguration.class) + static class Config { + + } + + static class SetupDatabase implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + postgres.start(); + TestPropertySourceUtils.addInlinedPropertiesToEnvironment(applicationContext, + "spring.datasource.url=" + postgres.getJdbcUrl()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseServiceConnectionIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseServiceConnectionIntegrationTests.java new file mode 100644 index 0000000000..d37448d2dc --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabaseServiceConnectionIntegrationTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2024 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.autoconfigure.jdbc; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for {@link AutoConfigureTestDatabase} with Testcontainers and a + * {@link ServiceConnection @ServiceConnection}. + * + * @author Phillip Webb + */ +@SpringBootTest +@AutoConfigureTestDatabase(replace = Replace.NON_TEST) +@Testcontainers(disabledWithoutDocker = true) +@OverrideAutoConfiguration(enabled = false) +class AutoConfigureTestDatabaseServiceConnectionIntegrationTests { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = TestImage.container(PostgreSQLContainer.class); + + @Autowired + private DataSource dataSource; + + @Test + void dataSourceIsNotReplaced() { + assertThat(this.dataSource).isInstanceOf(HikariDataSource.class).isNotInstanceOf(EmbeddedDatabase.class); + } + + @Configuration + @ImportAutoConfiguration(DataSourceAutoConfiguration.class) + static class Config { + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/resources/postgres-compose.yaml b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/resources/postgres-compose.yaml new file mode 100644 index 0000000000..cb721c823b --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/dockerTest/resources/postgres-compose.yaml @@ -0,0 +1,9 @@ +services: + database: + image: '{imageName}' + ports: + - '5432' + environment: + - 'POSTGRES_USER=myuser' + - 'POSTGRES_DB=mydatabase' + - 'POSTGRES_PASSWORD=secret' diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java index 9bed2317ed..7947e56993 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -26,10 +26,12 @@ import java.lang.annotation.Target; import javax.sql.DataSource; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.container.ContainerImageMetadata; import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; import org.springframework.boot.test.autoconfigure.properties.PropertyMapping; import org.springframework.boot.test.autoconfigure.properties.SkipPropertyMapping; import org.springframework.context.annotation.Primary; +import org.springframework.test.context.DynamicPropertySource; /** * Annotation that can be applied to a test class to configure a test database to use @@ -54,7 +56,7 @@ public @interface AutoConfigureTestDatabase { * @return the type of existing DataSource to replace */ @PropertyMapping(skip = SkipPropertyMapping.ON_DEFAULT_VALUE) - Replace replace() default Replace.ANY; + Replace replace() default Replace.NON_TEST; /** * The type of connection to be established when {@link #replace() replacing} the @@ -69,6 +71,21 @@ public @interface AutoConfigureTestDatabase { */ enum Replace { + /** + * Replace the DataSource bean unless it is auto-configured and connecting to a + * test database. The following types of connections are considered test + * databases: + *
    + *
  • Any bean definition that includes {@link ContainerImageMetadata} (including + * {@code @ServiceConnection} annotated Testcontainer databases, and connections + * created using Docker Compose)
  • + *
  • Any connection configured using a {@code spring.datasource.url} backed by a + * {@link DynamicPropertySource @DynamicPropertySource}
  • + *
+ * @since 3.4.0 + */ + NON_TEST, + /** * Replace the DataSource bean whether it was auto-configured or manually defined. */ diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java index c0c28e124c..fa1f07bf42 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -16,7 +16,9 @@ package org.springframework.boot.test.autoconfigure.jdbc; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.sql.DataSource; @@ -28,6 +30,8 @@ import org.springframework.aot.AotDetector; import org.springframework.beans.BeansException; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinitionHolder; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -37,8 +41,17 @@ import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.container.ContainerImageMetadata; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.BoundPropertiesTrackingBindHandler; +import org.springframework.boot.context.properties.source.ConfigurationProperty; +import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; +import org.springframework.boot.origin.PropertySourceOrigin; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Role; @@ -47,6 +60,8 @@ import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.type.MethodMetadata; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; import org.springframework.util.Assert; @@ -62,6 +77,23 @@ import org.springframework.util.ObjectUtils; @AutoConfiguration(before = DataSourceAutoConfiguration.class) public class TestDatabaseAutoConfiguration { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @ConditionalOnProperty(prefix = "spring.test.database", name = "replace", havingValue = "NON_TEST", + matchIfMissing = true) + static EmbeddedDataSourceBeanFactoryPostProcessor nonTestEmbeddedDataSourceBeanFactoryPostProcessor( + Environment environment) { + return new EmbeddedDataSourceBeanFactoryPostProcessor(environment, Replace.NON_TEST); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @ConditionalOnProperty(prefix = "spring.test.database", name = "replace", havingValue = "ANY") + static EmbeddedDataSourceBeanFactoryPostProcessor embeddedDataSourceBeanFactoryPostProcessor( + Environment environment) { + return new EmbeddedDataSourceBeanFactoryPostProcessor(environment, Replace.ANY); + } + @Bean @ConditionalOnProperty(prefix = "spring.test.database", name = "replace", havingValue = "AUTO_CONFIGURED") @ConditionalOnMissingBean @@ -69,19 +101,25 @@ public class TestDatabaseAutoConfiguration { return new EmbeddedDataSourceFactory(environment).getEmbeddedDatabase(); } - @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - @ConditionalOnProperty(prefix = "spring.test.database", name = "replace", havingValue = "ANY", - matchIfMissing = true) - static EmbeddedDataSourceBeanFactoryPostProcessor embeddedDataSourceBeanFactoryPostProcessor() { - return new EmbeddedDataSourceBeanFactoryPostProcessor(); - } - @Order(Ordered.LOWEST_PRECEDENCE) static class EmbeddedDataSourceBeanFactoryPostProcessor implements BeanDefinitionRegistryPostProcessor { + private static final ConfigurationPropertyName DATASOURCE_URL_PROPERTY = ConfigurationPropertyName + .of("spring.datasource.url"); + + private static final String DYNAMIC_VALUES_PROPERTY_SOURCE_CLASS = "org.springframework.test.context.support.DynamicValuesPropertySource"; + private static final Log logger = LogFactory.getLog(EmbeddedDataSourceBeanFactoryPostProcessor.class); + private final Environment environment; + + private final Replace replace; + + EmbeddedDataSourceBeanFactoryPostProcessor(Environment environment, Replace replace) { + this.environment = environment; + this.replace = replace; + } + @Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { if (AotDetector.useGeneratedArtifacts()) { @@ -98,7 +136,7 @@ public class TestDatabaseAutoConfiguration { private void process(BeanDefinitionRegistry registry, ConfigurableListableBeanFactory beanFactory) { BeanDefinitionHolder holder = getDataSourceBeanDefinition(beanFactory); - if (holder != null) { + if (holder != null && isReplaceable(beanFactory, holder)) { String beanName = holder.getBeanName(); boolean primary = holder.getBeanDefinition().isPrimary(); logger.info("Replacing '" + beanName + "' DataSource bean with " + (primary ? "primary " : "") @@ -135,6 +173,60 @@ public class TestDatabaseAutoConfiguration { return null; } + private boolean isReplaceable(ConfigurableListableBeanFactory beanFactory, BeanDefinitionHolder holder) { + if (this.replace == Replace.NON_TEST) { + return !isAutoConfigured(holder) || !isConnectingToTestDatabase(beanFactory); + } + return true; + } + + private boolean isAutoConfigured(BeanDefinitionHolder holder) { + if (holder.getBeanDefinition() instanceof AnnotatedBeanDefinition annotatedBeanDefinition) { + MethodMetadata factoryMethodMetadata = annotatedBeanDefinition.getFactoryMethodMetadata(); + return (factoryMethodMetadata != null) && (factoryMethodMetadata.getDeclaringClassName() + .startsWith("org.springframework.boot.autoconfigure.")); + } + return false; + } + + private boolean isConnectingToTestDatabase(ConfigurableListableBeanFactory beanFactory) { + return isUsingTestServiceConnection(beanFactory) || isUsingDynamicPropertySournce(); + } + + private boolean isUsingTestServiceConnection(ConfigurableListableBeanFactory beanFactory) { + for (String beanName : beanFactory.getBeanNamesForType(JdbcConnectionDetails.class)) { + try { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + if (ContainerImageMetadata.isPresent(beanDefinition)) { + return true; + } + } + catch (NoSuchBeanDefinitionException ex) { + // Ignore + } + } + return false; + } + + private boolean isUsingDynamicPropertySournce() { + List bound = new ArrayList<>(); + Binder.get(this.environment, new BoundPropertiesTrackingBindHandler(bound::add)) + .bind(DATASOURCE_URL_PROPERTY, Bindable.of(String.class)); + return (!bound.isEmpty()) ? isBoundToDynamicValuesPropertySource(bound.get(0)) : false; + } + + private boolean isBoundToDynamicValuesPropertySource(ConfigurationProperty configurationProperty) { + if (configurationProperty.getOrigin() instanceof PropertySourceOrigin origin) { + return isDynamicValuesPropertySource(origin.getPropertySource()); + } + return false; + } + + private boolean isDynamicValuesPropertySource(PropertySource propertySource) { + return propertySource != null + && DYNAMIC_VALUES_PROPERTY_SOURCE_CLASS.equals(propertySource.getClass().getName()); + } + } static class EmbeddedDataSourceFactoryBean implements FactoryBean, EnvironmentAware, InitializingBean { 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 index 95a8fce48f..a6909d890b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -24,6 +24,7 @@ import java.util.List; import org.testcontainers.containers.Container; import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.container.ContainerImageMetadata; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; @@ -65,7 +66,9 @@ class ContainerFieldsImporter { } private void registerBeanDefinition(BeanDefinitionRegistry registry, Field field, Container container) { + ContainerImageMetadata containerMetadata = new ContainerImageMetadata(container.getDockerImageName()); TestcontainerFieldBeanDefinition beanDefinition = new TestcontainerFieldBeanDefinition(field, container); + containerMetadata.addTo(beanDefinition); String beanName = generateBeanName(field); registry.registerBeanDefinition(beanName, beanDefinition); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java index 318ef6a5f9..491d61c5d6 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ConnectionDetailsRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -32,6 +32,7 @@ import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.autoconfigure.container.ContainerImageMetadata; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException; @@ -100,12 +101,14 @@ class ConnectionDetailsRegistrar { Arrays.asList(existingBeans)))); return; } + ContainerImageMetadata containerMetadata = new ContainerImageMetadata(source.getContainerImageName()); String beanName = getBeanName(source, connectionDetails); Class beanType = (Class) connectionDetails.getClass(); Supplier beanSupplier = () -> (T) connectionDetails; logger.debug(LogMessage.of(() -> "Registering '%s' for %s".formatted(beanName, source))); RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType, beanSupplier); beanDefinition.setAttribute(ServiceConnection.class.getName(), true); + containerMetadata.addTo(beanDefinition); registry.registerBeanDefinition(beanName, beanDefinition); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java index da480628d6..06a09e4e4a 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java @@ -52,6 +52,8 @@ public final class ContainerConnectionSource> implements private final Class containerType; + private final String containerImageName; + private final String connectionName; private final Set> connectionDetailsTypes; @@ -63,6 +65,7 @@ public final class ContainerConnectionSource> implements this.beanNameSuffix = beanNameSuffix; this.origin = origin; this.containerType = containerType; + this.containerImageName = containerImageName; this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName); this.connectionDetailsTypes = Set.of(annotation.getClassArray("type")); this.containerSupplier = containerSupplier; @@ -73,6 +76,7 @@ public final class ContainerConnectionSource> implements this.beanNameSuffix = beanNameSuffix; this.origin = origin; this.containerType = containerType; + this.containerImageName = containerImageName; this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName); this.connectionDetailsTypes = Set.of(annotation.type()); this.containerSupplier = containerSupplier; @@ -136,6 +140,10 @@ public final class ContainerConnectionSource> implements return this.origin; } + String getContainerImageName() { + return this.containerImageName; + } + String getConnectionName() { return this.connectionName; }