diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index cedb706ea07..6245d5b9da5 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -78,8 +78,10 @@ public class DocumentConfigurationProperties extends DefaultTask { snippets.add("application-properties.security", "Security Properties", this::securityPrefixes); snippets.add("application-properties.rsocket", "RSocket Properties", this::rsocketPrefixes); snippets.add("application-properties.actuator", "Actuator Properties", this::actuatorPrefixes); - snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes); snippets.add("application-properties.devtools", "Devtools Properties", this::devtoolsPrefixes); + snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes); + snippets.add("application-properties.testcontainers", "Testcontainers Properties", + this::testcontainersPrefixes); snippets.add("application-properties.testing", "Testing Properties", this::testingPrefixes); snippets.writeTo(this.outputDir.toPath()); } @@ -224,7 +226,11 @@ public class DocumentConfigurationProperties extends DefaultTask { } private void testingPrefixes(Config prefix) { - prefix.accept("spring.test"); + prefix.accept("spring.test."); + } + + private void testcontainersPrefixes(Config prefix) { + prefix.accept("spring.testcontainers."); } } diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index f2eac01891d..5cbee491b50 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -61,6 +61,7 @@ dependencies { asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-autoconfigure")) asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-devtools")) asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-docker-compose")) + asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-testcontainers")) autoConfiguration(project(path: ":spring-boot-project:spring-boot-autoconfigure", configuration: "autoConfigurationMetadata")) autoConfiguration(project(path: ":spring-boot-project:spring-boot-actuator-autoconfigure", configuration: "autoConfigurationMetadata")) @@ -74,6 +75,7 @@ dependencies { configurationProperties(project(path: ":spring-boot-project:spring-boot-docker-compose", configuration: "configurationPropertiesMetadata")) configurationProperties(project(path: ":spring-boot-project:spring-boot-devtools", configuration: "configurationPropertiesMetadata")) configurationProperties(project(path: ":spring-boot-project:spring-boot-test-autoconfigure", configuration: "configurationPropertiesMetadata")) + configurationProperties(project(path: ":spring-boot-project:spring-boot-testcontainers", configuration: "configurationPropertiesMetadata")) gradlePluginDocumentation(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "documentation")) diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc index 7c51da9f53f..ab3b79f7eb7 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc @@ -47,4 +47,6 @@ include::application-properties/devtools.adoc[] include::application-properties/docker-compose.adoc[] +include::application-properties/testcontainers.adoc[] + include::application-properties/testing.adoc[] 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 61923636363..ea0400bdf56 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 @@ -1072,6 +1072,9 @@ include::code:test/MyContainersConfiguration[] NOTE: The lifecycle of `Container` beans is automatically managed by Spring Boot. Containers will be started and stopped automatically. +TIP: You can use the configprop:spring.testcontainers.startup[] property to change how containers are started. +By default `sequential` startup is used, but you may also choose `parallel` if you wish to start multiple containers in parallel. + Once you have defined your test configuration, you can use the `with(...)` method to attach it to your test launcher: include::code:test/TestMyApplication[] diff --git a/spring-boot-project/spring-boot-testcontainers/build.gradle b/spring-boot-project/spring-boot-testcontainers/build.gradle index a0f64a191d8..de0546e4507 100644 --- a/spring-boot-project/spring-boot-testcontainers/build.gradle +++ b/spring-boot-project/spring-boot-testcontainers/build.gradle @@ -1,6 +1,7 @@ plugins { id "java-library" id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" id "org.springframework.boot.conventions" id "org.springframework.boot.deployed" id "org.springframework.boot.optional-dependencies" 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 087c4d71e60..41420bbb6f4 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 @@ -47,7 +47,8 @@ public class TestcontainersLifecycleApplicationContextInitializer } ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor()); - beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory)); + TestcontainersStartup startup = TestcontainersStartup.get(applicationContext.getEnvironment()); + beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory, startup)); } } 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 9a5ec1d10af..7033b961a29 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,9 +16,11 @@ package org.springframework.boot.testcontainers.lifecycle; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -58,48 +60,61 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo private final ConfigurableListableBeanFactory beanFactory; + private final TestcontainersStartup startup; + private volatile boolean containersInitialized = false; - TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory) { + TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory, + TestcontainersStartup startup) { this.beanFactory = beanFactory; + this.startup = startup; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof Startable startable) { - startable.start(); - } - if (this.beanFactory.isConfigurationFrozen()) { + if (!this.containersInitialized && this.beanFactory.isConfigurationFrozen()) { initializeContainers(); } return bean; } private void initializeContainers() { - if (this.containersInitialized) { - return; - } - this.containersInitialized = true; Set beanNames = new LinkedHashSet<>(); beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false))); beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false))); + initializeContainers(beanNames); + } + + private void initializeContainers(Set beanNames) { + List beans = new ArrayList<>(beanNames.size()); for (String beanName : beanNames) { try { - this.beanFactory.getBean(beanName); + beans.add(this.beanFactory.getBean(beanName)); } catch (BeanCreationException ex) { if (ex.contains(BeanCurrentlyInCreationException.class)) { - this.containersInitialized = false; return; } throw ex; } } - if (!beanNames.isEmpty()) { - logger.debug(LogMessage.format("Initialized container beans '%s'", beanNames)); + if (!this.containersInitialized) { + this.containersInitialized = true; + if (!beanNames.isEmpty()) { + logger.debug(LogMessage.format("Initialized container beans '%s'", beanNames)); + } + start(beans); } } + private void start(List beans) { + Set startables = beans.stream() + .filter(Startable.class::isInstance) + .map(Startable.class::cast) + .collect(Collectors.toCollection(LinkedHashSet::new)); + this.startup.start(startables); + } + @Override public boolean requiresDestruction(Object bean) { return bean instanceof Startable; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java new file mode 100644 index 00000000000..4554dbc83be --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java @@ -0,0 +1,94 @@ +/* + * 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 java.util.Collection; + +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.Startables; + +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; + +/** + * Testcontainers startup strategies. The strategy to use can be configured in the Spring + * {@link Environment} with a {@value #PROPERTY} property. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public enum TestcontainersStartup { + + /** + * Startup containers sequentially. + */ + SEQUENTIAL { + + @Override + void start(Collection startables) { + startables.forEach(Startable::start); + } + + }, + + /** + * Startup containers in parallel. + */ + PARALLEL { + + @Override + void start(Collection startables) { + Startables.deepStart(startables).join(); + } + + }; + + /** + * The {@link Environment} property used to change the {@link TestcontainersStartup} + * strategy. + */ + public static final String PROPERTY = "spring.testcontainers.startup"; + + abstract void start(Collection startables); + + static TestcontainersStartup get(ConfigurableEnvironment environment) { + return get((environment != null) ? environment.getProperty(PROPERTY) : null); + } + + private static TestcontainersStartup get(String value) { + if (value == null) { + return SEQUENTIAL; + } + String canonicalName = getCanonicalName(value); + for (TestcontainersStartup candidate : values()) { + if (candidate.name().equalsIgnoreCase(canonicalName)) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown '%s' property value '%s'".formatted(PROPERTY, value)); + } + + private static String getCanonicalName(String name) { + StringBuilder canonicalName = new StringBuilder(name.length()); + name.chars() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .forEach((c) -> canonicalName.append((char) c)); + return canonicalName.toString(); + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..708bb785490 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "spring.testcontainers.startup", + "type": "org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup", + "description": "Testcontainers startup modes.", + "defaultValue": "sequential" + } + ] +} 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 ee27dae34f7..dca0b39d1dc 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 @@ -16,13 +16,18 @@ package org.springframework.boot.testcontainers.lifecycle; +import java.util.Map; + import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import org.testcontainers.lifecycle.Startable; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.support.AbstractBeanFactory; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.MapPropertySource; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -104,6 +109,22 @@ class TestcontainersLifecycleApplicationContextInitializerTests { applicationContext.refresh(); } + @Test + void setupStartupBasedOnEnvironmentProperty() { + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.getEnvironment() + .getPropertySources() + .addLast(new MapPropertySource("test", Map.of("spring.testcontainers.startup", "parallel"))); + new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); + AbstractBeanFactory beanFactory = (AbstractBeanFactory) applicationContext.getBeanFactory(); + BeanPostProcessor beanPostProcessor = beanFactory.getBeanPostProcessors() + .stream() + .filter(TestcontainersLifecycleBeanPostProcessor.class::isInstance) + .findFirst() + .get(); + assertThat(beanPostProcessor).extracting("startup").isEqualTo(TestcontainersStartup.PARALLEL); + } + private AnnotationConfigApplicationContext createApplicationContext(Startable container) { AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java new file mode 100644 index 00000000000..fd88210ccab --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java @@ -0,0 +1,122 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.testcontainers.lifecycle.Startable; + +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link TestcontainersStartup}. + * + * @author Phillip Webb + */ +class TestcontainersStartupTests { + + private static final String PROPERTY = TestcontainersStartup.PROPERTY; + + private final AtomicInteger counter = new AtomicInteger(); + + @Test + void startWhenSquentialStartsSequentially() { + List startables = createTestStartables(100); + TestcontainersStartup.SEQUENTIAL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getIndex()).isEqualTo(i); + assertThat(startables.get(i).getThreadName()).isEqualTo(Thread.currentThread().getName()); + } + } + + @Test + void startWhenParallelStartsInParallel() { + List startables = createTestStartables(100); + TestcontainersStartup.PARALLEL.start(startables); + assertThat(startables.stream().map(TestStartable::getThreadName)).hasSizeGreaterThan(1); + } + + @Test + void getWhenNoPropertyReturnsDefault() { + MockEnvironment environment = new MockEnvironment(); + assertThat(TestcontainersStartup.get(environment)).isEqualTo(TestcontainersStartup.SEQUENTIAL); + } + + @Test + void getWhenPropertyReturnsBasedOnValue() { + MockEnvironment environment = new MockEnvironment(); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQUENTIAL"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "sequential"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQuenTIaL"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "S-E-Q-U-E-N-T-I-A-L"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "parallel"))) + .isEqualTo(TestcontainersStartup.PARALLEL); + } + + @Test + void getWhenUnknownPropertyThrowsException() { + MockEnvironment environment = new MockEnvironment(); + assertThatIllegalArgumentException() + .isThrownBy(() -> TestcontainersStartup.get(environment.withProperty(PROPERTY, "bad"))) + .withMessage("Unknown 'spring.testcontainers.startup' property value 'bad'"); + } + + private List createTestStartables(int size) { + List testStartables = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + testStartables.add(new TestStartable()); + } + return testStartables; + } + + private class TestStartable implements Startable { + + private int index; + + private String threadName; + + @Override + public void start() { + this.index = TestcontainersStartupTests.this.counter.getAndIncrement(); + this.threadName = Thread.currentThread().getName(); + } + + @Override + public void stop() { + } + + int getIndex() { + return this.index; + } + + String getThreadName() { + return this.threadName; + } + + } + +}