Prevent early initialization of Container beans

Update testcontainers auto-configuration so that `Container` bean
instances are no longer needed when registering `ConnectionDetails`
beans. Registration now occurs based on the bean type and the `name`
attribute of `@ServiceConnection`.

Fixes gh-35168
This commit is contained in:
Phillip Webb
2023-04-30 19:23:49 -07:00
parent c21cf31853
commit b4cd2572d5
12 changed files with 356 additions and 151 deletions

View File

@@ -0,0 +1,105 @@
/*
* 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.service.connection;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException;
import org.springframework.boot.origin.Origin;
import org.springframework.core.annotation.MergedAnnotation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ConnectionDetailsRegistrar}.
*
* @author Phillip Webb
*/
class ConnectionDetailsRegistrarTests {
private Origin origin;
private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation;
private ContainerConnectionSource<?> source;
private ConnectionDetailsFactories factories;
@BeforeEach
void setup() {
this.origin = mock(Origin.class);
this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, null,
this.annotation, () -> this.container);
this.factories = mock(ConnectionDetailsFactories.class);
}
@Test
void registerBeanDefinitionsWhenConnectionDetailsFactoryNotFoundAndNoConnectionNameThrowsExceptionWithBetterMessage() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willThrow(new ConnectionDetailsFactoryNotFoundException("fail"));
assertThatExceptionOfType(ConnectionDetailsFactoryNotFoundException.class)
.isThrownBy(() -> registrar.registerBeanDefinitions(beanFactory, this.source))
.withMessage("fail. You may need to add a 'name' to your @ServiceConnection annotation");
}
@Test
void registerBeanDefinitionsWhenExistingBeanSkipsRegistration() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
beanFactory.registerBeanDefinition("testbean", new RootBeanDefinition(CustomTestConnectionDetails.class));
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails()));
registrar.registerBeanDefinitions(beanFactory, this.source);
assertThat(beanFactory.getBean(TestConnectionDetails.class)).isInstanceOf(CustomTestConnectionDetails.class);
}
@Test
void registerBeanDefinitionsRegistersDefinition() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails()));
registrar.registerBeanDefinitions(beanFactory, this.source);
assertThat(beanFactory.getBean(TestConnectionDetails.class)).isNotNull();
}
static class TestConnectionDetails implements ConnectionDetails {
}
static class CustomTestConnectionDetails extends TestConnectionDetails {
}
}

View File

@@ -28,9 +28,11 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.origin.Origin;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryTests.TestContainerConnectionDetailsFactory.TestContainerConnectionDetails;
import org.springframework.core.annotation.MergedAnnotation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock;
/**
@@ -59,8 +61,8 @@ class ContainerConnectionDetailsFactoryTests {
this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container,
this.annotation);
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
}
@Test
@@ -88,7 +90,7 @@ class ContainerConnectionDetailsFactoryTests {
void getConnectionDetailsWhenContainerTypeDoesNotMatchReturnsNull() {
ElasticsearchContainer container = mock(ElasticsearchContainer.class);
ContainerConnectionSource<?> source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin,
container, this.annotation);
ElasticsearchContainer.class, container.getDockerImageName(), this.annotation, () -> container);
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
ConnectionDetails connectionDetails = getConnectionDetails(factory, source);
assertThat(connectionDetails).isNull();
@@ -101,10 +103,26 @@ class ContainerConnectionDetailsFactoryTests {
assertThat(Origin.from(connectionDetails)).isSameAs(this.origin);
}
@Test
void getContainerWhenNotInitializedThrowsException() {
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source);
assertThatIllegalStateException().isThrownBy(() -> connectionDetails.callGetContainer())
.withMessage("Container cannot be obtained before the connection details bean has been initialized");
}
@Test
void getContainerWhenInitializedReturnsSuppliedContainer() throws Exception {
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source);
connectionDetails.afterPropertiesSet();
assertThat(connectionDetails.callGetContainer()).isSameAs(this.container);
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private ConnectionDetails getConnectionDetails(ConnectionDetailsFactory<?, ?> factory,
private TestContainerConnectionDetails getConnectionDetails(ConnectionDetailsFactory<?, ?> factory,
ContainerConnectionSource<?> source) {
return ((ConnectionDetailsFactory) factory).getConnectionDetails(source);
return (TestContainerConnectionDetails) ((ConnectionDetailsFactory) factory).getConnectionDetails(source);
}
/**
@@ -127,8 +145,8 @@ class ContainerConnectionDetailsFactoryTests {
return new TestContainerConnectionDetails(source);
}
private static final class TestContainerConnectionDetails
extends ContainerConnectionDetails<JdbcDatabaseContainer<?>> implements JdbcConnectionDetails {
static final class TestContainerConnectionDetails extends ContainerConnectionDetails<JdbcDatabaseContainer<?>>
implements JdbcConnectionDetails {
private TestContainerConnectionDetails(ContainerConnectionSource<JdbcDatabaseContainer<?>> source) {
super(source);
@@ -149,6 +167,10 @@ class ContainerConnectionDetailsFactoryTests {
return "jdbc:example";
}
JdbcDatabaseContainer<?> callGetContainer() {
return super.getContainer();
}
}
}

View File

@@ -46,7 +46,7 @@ class ContainerConnectionSourceTests {
private Origin origin;
private JdbcDatabaseContainer<?> container;
private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation;
@@ -59,92 +59,102 @@ class ContainerConnectionSourceTests {
this.container = mock(PostgreSQLContainer.class);
given(this.container.getDockerImageName()).willReturn("postgres");
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container,
this.annotation);
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
}
@Test
void acceptsWhenContainerIsNotInstanceOfContainerTypeReturnsFalse() {
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = ElasticsearchContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse();
void acceptsWhenContainerIsNotInstanceOfRequiredContainerTypeReturnsFalse() {
String requiredConnectionName = null;
Class<?> requiredContainerType = ElasticsearchContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
}
@Test
void acceptsWhenContainerIsInstanceOfContainerTypeReturnsTrue() {
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
void acceptsWhenContainerIsInstanceOfRequiredContainerTypeReturnsTrue() {
String requiredConnectionName = null;
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
void acceptsWhenConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() {
void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() {
setupSourceAnnotatedWithName("myname");
String connectionName = "othername";
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse();
String requiredConnectionName = "othername";
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
}
@Test
void acceptsWhenConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() {
String connectionName = "othername";
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse();
void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() {
String requiredConnectionName = "othername";
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
}
@Test
void acceptsWhenConnectionNameIsUnrestrictedReturnsTrue() {
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
void acceptsWhenRequiredConnectionNameIsUnrestrictedReturnsTrue() {
String requiredConnectionName = null;
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
void acceptsWhenConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() {
void acceptsWhenRequiredConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() {
setupSourceAnnotatedWithName("myname");
String connectionName = "myname";
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
String requiredConnectionName = "myname";
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
void acceptsWhenConnectionNameMatchesNameTakenFromContainerReturnsTrue() {
String connectionName = "postgres";
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
void acceptsWhenRequiredConnectionNameMatchesNameTakenFromContainerReturnsTrue() {
String requiredConnectionName = "postgres";
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
void acceptsWhenConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() {
void acceptsWhenRequiredConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() {
setupSourceAnnotatedWithType(ElasticsearchConnectionDetails.class);
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse();
String requiredConnectionName = null;
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
}
@Test
void acceptsWhenConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() {
void acceptsWhenRequiredConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() {
setupSourceAnnotatedWithType(JdbcConnectionDetails.class);
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
String requiredConnectionName = null;
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
void acceptsWhenConnectionDetailsTypeIsNotRestrictedReturnsTrue() {
String connectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class;
Class<?> containerType = JdbcDatabaseContainer.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue();
void acceptsWhenRequiredConnectionDetailsTypeIsNotRestrictedReturnsTrue() {
String requiredConnectionName = null;
Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
}
@Test
@@ -158,8 +168,8 @@ class ContainerConnectionSourceTests {
}
@Test
void getContainerReturnsContainer() {
assertThat(this.source.getContainer()).isSameAs(this.container);
void getContainerSupplierReturnsSupplierSupplyingContainer() {
assertThat(this.source.getContainerSupplier().get()).isSameAs(this.container);
}
@Test
@@ -169,15 +179,15 @@ class ContainerConnectionSourceTests {
private void setupSourceAnnotatedWithName(String name) {
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", name, "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container,
this.annotation);
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
}
private void setupSourceAnnotatedWithType(Class<?> type) {
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "", "type", new Class<?>[] { type }));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container,
this.annotation);
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
}
}

View File

@@ -94,7 +94,7 @@ class ServiceConnectionAutoConfigurationTests {
static class ContainerConfiguration {
@Bean
@ServiceConnection
@ServiceConnection("redis")
RedisContainer redisContainer() {
return new RedisContainer();
}

View File

@@ -80,7 +80,7 @@ class ServiceConnectionContextCustomizerFactoryTests {
ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
.createContextCustomizer(SingleServiceConnection.class, null);
ContainerConnectionSource<?> source = customizer.getSources().get(0);
assertThat(source.getBeanNameSuffix()).isEqualTo("SingleServiceConnectionService1");
assertThat(source.getBeanNameSuffix()).isEqualTo("test");
}
@Test

View File

@@ -22,7 +22,6 @@ import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.springframework.beans.factory.config.BeanDefinition;
@@ -51,11 +50,9 @@ import static org.mockito.Mockito.spy;
*/
class ServiceConnectionContextCustomizerTests {
private String beanNameSuffix;
private Origin origin;
private JdbcDatabaseContainer<?> container;
private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation;
@@ -65,13 +62,12 @@ class ServiceConnectionContextCustomizerTests {
@BeforeEach
void setup() {
this.beanNameSuffix = "MyBean";
this.origin = mock(Origin.class);
this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container,
this.annotation);
this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class,
this.container.getDockerImageName(), this.annotation, () -> this.container);
this.factories = mock(ConnectionDetailsFactories.class);
}
@@ -89,7 +85,7 @@ class ServiceConnectionContextCustomizerTests {
customizer.customizeContext(context, mergedConfig);
ArgumentCaptor<BeanDefinition> beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class);
then(beanFactory).should()
.registerBeanDefinition(eq("testJdbcConnectionDetailsForMyBean"), beanDefinitionCaptor.capture());
.registerBeanDefinition(eq("testJdbcConnectionDetailsForTest"), beanDefinitionCaptor.capture());
RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue();
assertThat(beanDefinition.getInstanceSupplier().get()).isSameAs(connectionDetails);
assertThat(beanDefinition.getBeanClass()).isEqualTo(TestJdbcConnectionDetails.class);