diff --git a/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionStartsConnectionOnceIntegrationTest.java b/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionStartsConnectionOnceIntegrationTest.java new file mode 100644 index 00000000000..45f96e98b91 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionStartsConnectionOnceIntegrationTest.java @@ -0,0 +1,76 @@ +/* + * 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.testcontainers.service.connection; + +import java.util.concurrent.atomic.AtomicInteger; + +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.testcontainers.utility.DockerImageName; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests to ensure containers are started only once. + * + * @author Phillip Webb + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class ServiceConnectionStartsConnectionOnceIntegrationTest { + + @Container + @ServiceConnection + static final StartCountingPostgreSQLContainer postgres = TestImage + .container(StartCountingPostgreSQLContainer.class); + + @Test + void startedOnlyOnce() { + assertThat(postgres.startCount.get()).isOne(); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(DataSourceAutoConfiguration.class) + static class TestConfiguration { + + } + + static class StartCountingPostgreSQLContainer extends PostgreSQLContainer { + + final AtomicInteger startCount = new AtomicInteger(); + + StartCountingPostgreSQLContainer(DockerImageName dockerImageName) { + super(dockerImageName); + } + + @Override + public void start() { + this.startCount.incrementAndGet(); + super.start(); + } + + } + +} 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 f1754048e98..a09d7906c99 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 @@ -97,7 +97,7 @@ class TestcontainersLifecycleBeanPostProcessor } else if (this.startables.get() == Startables.STARTED) { logger.trace(LogMessage.format("Starting container %s", beanName)); - startableBean.start(); + TestcontainersStartup.start(startableBean); } } return bean; 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 index 00009a07fa6..9a44bf3aa85 100644 --- 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 @@ -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. @@ -17,7 +17,13 @@ package org.springframework.boot.testcontainers.lifecycle; import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.testcontainers.containers.Container; import org.testcontainers.lifecycle.Startable; import org.testcontainers.lifecycle.Startables; @@ -40,7 +46,7 @@ public enum TestcontainersStartup { @Override void start(Collection startables) { - startables.forEach(Startable::start); + startables.forEach(TestcontainersStartup::start); } }, @@ -52,7 +58,8 @@ public enum TestcontainersStartup { @Override void start(Collection startables) { - Startables.deepStart(startables).join(); + SingleStartables singleStartables = new SingleStartables(); + Startables.deepStart(startables.stream().map(singleStartables::getOrCreate)).join(); } }; @@ -91,4 +98,69 @@ public enum TestcontainersStartup { return canonicalName.toString(); } + /** + * Start the given {@link Startable} unless is's detected as already running. + * @param startable the startable to start + * @since 3.4.1 + */ + public static void start(Startable startable) { + if (!isRunning(startable)) { + startable.start(); + } + } + + private static boolean isRunning(Startable startable) { + try { + return (startable instanceof Container container) && container.isRunning(); + } + catch (Throwable ex) { + return false; + + } + } + + /** + * Tracks and adapts {@link Startable} instances to use + * {@link TestcontainersStartup#start(Startable)} so containers are only started once + * even when calling {@link Startables#deepStart(java.util.stream.Stream)}. + */ + private static final class SingleStartables { + + private final Map adapters = new HashMap<>(); + + SingleStartable getOrCreate(Startable startable) { + return this.adapters.computeIfAbsent(startable, this::create); + } + + private SingleStartable create(Startable startable) { + return new SingleStartable(this, startable); + } + + record SingleStartable(SingleStartables singleStartables, Startable startable) implements Startable { + + @Override + public Set getDependencies() { + Set dependencies = this.startable.getDependencies(); + if (dependencies.isEmpty()) { + return dependencies; + } + return dependencies.stream() + .map(this.singleStartables::getOrCreate) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Override + public void start() { + TestcontainersStartup.start(this.startable); + } + + @Override + public void stop() { + this.startable.stop(); + } + + } + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java index af6e6f916e7..14518b982d8 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java @@ -33,6 +33,7 @@ import org.springframework.boot.autoconfigure.service.connection.ConnectionDetai import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; import org.springframework.boot.origin.Origin; import org.springframework.boot.origin.OriginProvider; +import org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.ResolvableType; @@ -188,7 +189,7 @@ public abstract class ContainerConnectionDetailsFactory, Assert.state(this.container != null, "Container cannot be obtained before the connection details bean has been initialized"); if (this.container instanceof Startable startable) { - startable.start(); + TestcontainersStartup.start(startable); } return this.container; } 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 index b50e6ef63a5..38c3857b970 100644 --- 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 @@ -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. @@ -17,10 +17,13 @@ package org.springframework.boot.testcontainers.lifecycle; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.lifecycle.Startable; import org.springframework.mock.env.MockEnvironment; @@ -39,6 +42,16 @@ class TestcontainersStartupTests { private final AtomicInteger counter = new AtomicInteger(); + @Test + void startSingleStartsOnlyOnce() { + TestStartable startable = new TestStartable(); + assertThat(startable.startCount).isZero(); + TestcontainersStartup.start(startable); + assertThat(startable.startCount).isOne(); + TestcontainersStartup.start(startable); + assertThat(startable.startCount).isOne(); + } + @Test void startWhenSquentialStartsSequentially() { List startables = createTestStartables(100); @@ -49,6 +62,22 @@ class TestcontainersStartupTests { } } + @Test + void startWhenSquentialStartsOnlyOnce() { + List startables = createTestStartables(10); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isZero(); + } + TestcontainersStartup.SEQUENTIAL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isOne(); + } + TestcontainersStartup.SEQUENTIAL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isOne(); + } + } + @Test void startWhenParallelStartsInParallel() { List startables = createTestStartables(100); @@ -56,6 +85,47 @@ class TestcontainersStartupTests { assertThat(startables.stream().map(TestStartable::getThreadName)).hasSizeGreaterThan(1); } + @Test + void startWhenParallelStartsOnlyOnce() { + List startables = createTestStartables(10); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isZero(); + } + TestcontainersStartup.PARALLEL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isOne(); + } + TestcontainersStartup.PARALLEL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getStartCount()).isOne(); + } + } + + @Test + void startWhenParallelStartsDependenciesOnlyOnce() { + List dependencies = createTestStartables(10); + TestStartable first = new TestStartable(dependencies); + TestStartable second = new TestStartable(dependencies); + List startables = List.of(first, second); + assertThat(first.getStartCount()).isZero(); + assertThat(second.getStartCount()).isZero(); + for (int i = 0; i < startables.size(); i++) { + assertThat(dependencies.get(i).getStartCount()).isZero(); + } + TestcontainersStartup.PARALLEL.start(startables); + assertThat(first.getStartCount()).isOne(); + assertThat(second.getStartCount()).isOne(); + for (int i = 0; i < startables.size(); i++) { + assertThat(dependencies.get(i).getStartCount()).isOne(); + } + TestcontainersStartup.PARALLEL.start(startables); + assertThat(first.getStartCount()).isOne(); + assertThat(second.getStartCount()).isOne(); + for (int i = 0; i < startables.size(); i++) { + assertThat(dependencies.get(i).getStartCount()).isOne(); + } + } + @Test void getWhenNoPropertyReturnsDefault() { MockEnvironment environment = new MockEnvironment(); @@ -93,20 +163,43 @@ class TestcontainersStartupTests { return testStartables; } - private final class TestStartable implements Startable { + private class TestStartable extends GenericContainer { + + private int startCount; private int index; private String threadName; + TestStartable() { + super("test"); + } + + TestStartable(Collection startables) { + super("test"); + this.dependencies.addAll(startables); + } + + @Override + public Set getDependencies() { + return this.dependencies; + } + @Override public void start() { + this.startCount++; this.index = TestcontainersStartupTests.this.counter.getAndIncrement(); this.threadName = Thread.currentThread().getName(); } @Override public void stop() { + this.startCount--; + } + + @Override + public boolean isRunning() { + return this.startCount > 0; } int getIndex() { @@ -117,6 +210,10 @@ class TestcontainersStartupTests { return this.threadName; } + int getStartCount() { + return this.startCount; + } + } }