diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java index 7c768c71dc..7c05a39583 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java @@ -18,6 +18,7 @@ package org.springframework.boot.testcontainers.lifecycle; import org.testcontainers.lifecycle.Startable; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; @@ -33,7 +34,9 @@ public class TestcontainersLifecycleApplicationContextInitializer @Override public void initialize(ConfigurableApplicationContext applicationContext) { - applicationContext.getBeanFactory().addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor()); + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor()); + beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory)); } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanFactoryPostProcessor.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanFactoryPostProcessor.java new file mode 100644 index 0000000000..3590095053 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanFactoryPostProcessor.java @@ -0,0 +1,57 @@ +/* + * 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.lifecycle; + +import org.testcontainers.lifecycle.Startable; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** + * {@link BeanFactoryPostProcessor} to prevent {@link AutoCloseable} destruction calls so + * that {@link TestcontainersLifecycleBeanFactoryPostProcessor} can be smarter about which + * containers to close. + * + * @author Phillip Webb + * @author Stephane Nicoll + * @see TestcontainersLifecycleApplicationContextInitializer + */ +@Order(Ordered.LOWEST_PRECEDENCE) +class TestcontainersLifecycleBeanFactoryPostProcessor implements BeanFactoryPostProcessor { + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + for (String beanName : beanFactory.getBeanNamesForType(Startable.class, false, false)) { + try { + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + String destroyMethodName = beanDefinition.getDestroyMethodName(); + if (destroyMethodName == null || AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName)) { + beanDefinition.setDestroyMethodName(""); + } + } + catch (NoSuchBeanDefinitionException ex) { + } + } + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java index 710261aa06..ef37027370 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java @@ -16,10 +16,17 @@ package org.springframework.boot.testcontainers.lifecycle; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.lifecycle.Startable; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.DestructionAwareBeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; /** * {@link BeanPostProcessor} to manage the lifecycle of {@link Startable startable @@ -29,7 +36,14 @@ import org.springframework.beans.factory.config.BeanPostProcessor; * @author Stephane Nicoll * @see TestcontainersLifecycleApplicationContextInitializer */ -class TestcontainersLifecycleBeanPostProcessor implements BeanPostProcessor { +@Order(Ordered.LOWEST_PRECEDENCE) +class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPostProcessor { + + private final ConfigurableListableBeanFactory beanFactory; + + TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { @@ -39,4 +53,31 @@ class TestcontainersLifecycleBeanPostProcessor implements BeanPostProcessor { return bean; } + @Override + public boolean requiresDestruction(Object bean) { + return bean instanceof Startable; + } + + @Override + public void postProcessBeforeDestruction(Object bean, String beanName) throws BeansException { + if (bean instanceof Startable startable && !isDestroyedByFramework(beanName) && !isReusedContainer(bean)) { + startable.close(); + } + } + + private boolean isDestroyedByFramework(String beanName) { + try { + BeanDefinition beanDefinition = this.beanFactory.getBeanDefinition(beanName); + String destroyMethodName = beanDefinition.getDestroyMethodName(); + return !"".equals(destroyMethodName); + } + catch (NoSuchBeanDefinitionException ex) { + return false; + } + } + + private boolean isReusedContainer(Object bean) { + return (bean instanceof GenericContainer container) && container.isShouldBeReused(); + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java index 589de361b3..d06c993f27 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java @@ -17,43 +17,90 @@ package org.springframework.boot.testcontainers.lifecycle; import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.lifecycle.Startable; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; /** - * Tests for {@link TestcontainersLifecycleApplicationContextInitializer}. + * Tests for {@link TestcontainersLifecycleApplicationContextInitializer} and + * {@link TestcontainersLifecycleBeanPostProcessor} and + * {@link TestcontainersLifecycleBeanFactoryPostProcessor}. * * @author Stephane Nicoll + * @author Phillip Webb */ class TestcontainersLifecycleApplicationContextInitializerTests { @Test void whenStartableBeanInvokesStartOnRefresh() { Startable container = mock(Startable.class); - try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) { - applicationContext.registerBean("container", Startable.class, () -> container); - new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); - then(container).shouldHaveNoInteractions(); - applicationContext.refresh(); - then(container).should().start(); - } + AnnotationConfigApplicationContext applicationContext = createApplicationContext(container); + then(container).shouldHaveNoInteractions(); + applicationContext.refresh(); + then(container).should().start(); + applicationContext.close(); + } + + @Test + void whenStartableBeanInvokesCloseOnShutdown() { + Startable container = mock(Startable.class); + AnnotationConfigApplicationContext applicationContext = createApplicationContext(container); + applicationContext.refresh(); + then(container).should(never()).close(); + applicationContext.close(); + then(container).should(times(1)).close(); } @Test - void whenStartableBeanInvokesDestroyOnShutdown() { - Startable mock = mock(Startable.class); + void whenReusableContainerBeanInvokesStartButNotClose() { + GenericContainer container = mock(GenericContainer.class); + given(container.isShouldBeReused()).willReturn(true); + AnnotationConfigApplicationContext applicationContext = createApplicationContext(container); + then(container).shouldHaveNoInteractions(); + applicationContext.refresh(); + then(container).should().start(); + applicationContext.close(); + then(container).should(never()).close(); + } + + @Test + void whenReusableContainerBeanFromConfigurationInvokesStartButNotClose() { AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); - applicationContext.registerBean("container", Startable.class, () -> mock); new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); + applicationContext.register(ReusableContainerConfiguration.class); applicationContext.refresh(); - then(mock).should(never()).close(); + GenericContainer container = applicationContext.getBean(GenericContainer.class); + then(container).should().start(); applicationContext.close(); - then(mock).should().close(); + then(container).should(never()).close(); + } + + private AnnotationConfigApplicationContext createApplicationContext(Startable container) { + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); + applicationContext.registerBean("container", Startable.class, () -> container); + return applicationContext; + } + + @Configuration + static class ReusableContainerConfiguration { + + @Bean + GenericContainer container() { + GenericContainer container = mock(GenericContainer.class); + given(container.isShouldBeReused()).willReturn(true); + return container; + } + } }