Add testcontainers service connection auto-configuration

Add auto-configuration for `Container` beans that are also
annotated with `@ServiceConnection`. This commit allow
testcontainers to be used at development time and a new section
has been added to the documentation to describe the feature.

Closes gh-35022
pull/35031/head
Phillip Webb 2 years ago
parent 3b92173a66
commit 5ac48f5f15

@ -53,6 +53,7 @@ dependencies {
autoConfiguration(project(path: ":spring-boot-project:spring-boot-autoconfigure", configuration: "autoConfigurationMetadata"))
autoConfiguration(project(path: ":spring-boot-project:spring-boot-actuator-autoconfigure", configuration: "autoConfigurationMetadata"))
autoConfiguration(project(path: ":spring-boot-project:spring-boot-devtools", configuration: "autoConfigurationMetadata"))
autoConfiguration(project(path: ":spring-boot-project:spring-boot-testcontainers", configuration: "autoConfigurationMetadata"))
configurationProperties(project(path: ":spring-boot-project:spring-boot", configuration: "configurationPropertiesMetadata"))
configurationProperties(project(path: ":spring-boot-project:spring-boot-actuator", configuration: "configurationPropertiesMetadata"))

@ -1006,6 +1006,44 @@ The above configuration allows Neo4j-related beans in the application to communi
[[features.testing.testcontainers.at-development-time]]
==== Using Testcontainers at Development Time
As well as using Testcontainers for integration testing, it's also possible to use them at development time.
This approach allows developers to quickly start containers for the services that the application depends on, removing the need to manually provision things like database servers.
Using Testcontaners in this way provides functionality similar to Docker Compose, except that your container configuration is in Java rather than YAML.
To use Testcontainers at development time you need to launch your application using your "`test`" classpath rather than "`main`".
This will allow you to access all declared test dependencies and give you a natural place to write your test configuration.
To create a test launchable version of your application you should create an "`Application`" class in the `src/test` directory.
For example, if your main application is in `src/main/java/com/example/MyApplication.java`, you should create `src/test/java/com/example/TestMyApplication.java`
The `TestMyApplication` class can use the `SpringApplication.from(...)` method to launch the real application:
include::code:launch/TestMyApplication[]
You'll also need to define the `Container` instances that you want to start along with your application.
To do this, you need to make sure that the `spring-boot-testcontainers` module has been added as a `test` dependency.
Once that has been done, you can create a `@TestConfiguration` class that declares `@Bean` methods for the containers you want to start.
You can also annotate your `@Bean` methods with `@ServiceConnection` in order to create `ConnectionDetails` beans.
See <<features#features.testing.testcontainers.service-connections, the service connections>> section above for details of the supported technologies.
A typical Testcontainers configuration would look like this:
include::code:test/MyContainersConfiguration[]
NOTE: The lifecycle of `Container` beans is automatically managed by Spring Boot.
Containers will be started and stopped automatically.
Once you have defined your test configuration, you can use the `with(...)` method to attach it to your test launcher:
include::code:test/TestMyApplication[]
You can now launch `TestMyApplication` as you would any regular Java `main` method application to start your application and the containers that it needs to run.
[[features.testing.utilities]]
=== Test Utilities
A few test utility classes that are generally useful when testing your application are packaged as part of `spring-boot`.

@ -0,0 +1,24 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.launch;
public class MyApplication {
public static void main(String[] args) {
}
}

@ -0,0 +1,27 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.launch;
import org.springframework.boot.SpringApplication;
public class TestMyApplication {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main).run(args);
}
}

@ -0,0 +1,24 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test;
public class MyApplication {
public static void main(String[] args) {
}
}

@ -0,0 +1,34 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test;
import org.testcontainers.containers.Neo4jContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
@TestConfiguration(proxyBeanMethods = false)
public class MyContainersConfiguration {
@Bean
@ServiceConnection
public Neo4jContainer<?> neo4jContainer() {
return new Neo4jContainer<>("neo4j:5");
}
}

@ -0,0 +1,27 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test;
import org.springframework.boot.SpringApplication;
public class TestMyApplication {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main).run(args);
}
}

@ -0,0 +1,28 @@
/*
* Copyright 2012-2022 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.docs.features.testing.testcontainers.atdevelopmenttime.launch
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.docs.features.springapplication.MyApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class MyApplication
fun main(args: Array<String>) {
runApplication<MyApplication>(*args)
}

@ -0,0 +1,27 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.launch
import org.springframework.boot.SpringApplication
import org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.launch.main as myApplicationMain
object Main {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.from(::myApplicationMain).run(*args)
}
}

@ -0,0 +1,28 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.docs.features.springapplication.MyApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class MyApplication
fun main(args: Array<String>) {
runApplication<MyApplication>(*args)
}

@ -0,0 +1,34 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test
import org.testcontainers.containers.Neo4jContainer
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean;
@TestConfiguration(proxyBeanMethods = false)
class MyContainersConfiguration {
@Bean
@ServiceConnection
fun neo4jContainer(): Neo4jContainer<*> {
return Neo4jContainer("neo4j:5")
}
}

@ -0,0 +1,27 @@
/*
* 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.docs.features.testing.testcontainers.atdevelopmenttime.test
import org.springframework.boot.SpringApplication
import org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test.main as myApplicationMain
object Main {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.from(::myApplicationMain).with(MyContainersConfiguration::class.java).run(*args)
}
}

@ -0,0 +1,72 @@
/*
* 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.Objects;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.origin.Origin;
/**
* {@link Origin} backed by a Spring Bean.
*
* @author Phillip Webb
*/
class BeanOrigin implements Origin {
private final String beanName;
private final BeanDefinition beanDefinition;
BeanOrigin(String beanName, BeanDefinition beanDefinition) {
this.beanName = beanName;
this.beanDefinition = beanDefinition;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
BeanOrigin other = (BeanOrigin) obj;
return Objects.equals(this.beanName, other.beanName) && Objects
.equals(this.beanDefinition.getResourceDescription(), other.beanDefinition.getResourceDescription());
}
@Override
public int hashCode() {
return this.beanName.hashCode();
}
@Override
public String toString() {
String resourceDescription = this.beanDefinition.getResourceDescription();
StringBuilder result = new StringBuilder();
result.append("Bean '");
result.append(this.beanName);
result.append("'");
if (resourceDescription != null) {
result.append(" defined in ");
result.append(resourceDescription);
}
return result.toString();
}
}

@ -0,0 +1,97 @@
/*
* 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.ArrayList;
import java.util.List;
import java.util.Set;
import org.testcontainers.containers.Container;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories;
import org.springframework.boot.origin.Origin;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.Ordered;
import org.springframework.core.type.AnnotationMetadata;
/**
* {@link org.springframework.boot.autoconfigure.EnableAutoConfiguration
* Auto-configuration} for {@link ServiceConnection @ServiceConnection} annotated
* {@link Container} beans.
*
* @author Phillip Webb
* @since 3.1.0
*/
@AutoConfiguration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Import(ServiceConnectionAutoConfiguration.Registrar.class)
public class ServiceConnectionAutoConfiguration {
ServiceConnectionAutoConfiguration() {
}
static class Registrar implements ImportBeanDefinitionRegistrar {
private final BeanFactory beanFactory;
Registrar(BeanFactory beanFactory) {
this.beanFactory = beanFactory;
}
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
if (this.beanFactory instanceof ConfigurableListableBeanFactory listableBeanFactory) {
ConnectionDetailsFactories connectionDetailsFactories = new ConnectionDetailsFactories();
List<ContainerConnectionSource<?, ?>> sources = getSources(listableBeanFactory);
new ContainerConnectionSourcesRegistrar(listableBeanFactory, connectionDetailsFactories, sources)
.registerBeanDefinitions(registry);
}
}
private List<ContainerConnectionSource<?, ?>> getSources(ConfigurableListableBeanFactory beanFactory) {
List<ContainerConnectionSource<?, ?>> sources = new ArrayList<>();
for (String candidate : beanFactory.getBeanNamesForType(Container.class)) {
Set<ServiceConnection> annotations = beanFactory.findAllAnnotationsOnBean(candidate,
ServiceConnection.class, false);
if (!annotations.isEmpty()) {
addSources(sources, beanFactory, candidate, annotations);
}
}
return sources;
}
private void addSources(List<ContainerConnectionSource<?, ?>> sources,
ConfigurableListableBeanFactory beanFactory, String beanName, Set<ServiceConnection> annotations) {
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
Origin origin = new BeanOrigin(beanName, beanDefinition);
Container<?> container = beanFactory.getBean(beanName, Container.class);
for (ServiceConnection annotation : annotations) {
sources.add(new ContainerConnectionSource<>(beanName, origin, container, annotation));
}
}
}
}

@ -0,0 +1,111 @@
/*
* 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 org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisConnectionDetails;
import org.springframework.boot.testcontainers.lifecycle.TestcontainersLifecycleApplicationContextInitializer;
import org.springframework.boot.testsupport.testcontainers.RedisContainer;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ServiceConnectionAutoConfiguration}.
*
* @author Phillip Webb
*/
class ServiceConnectionAutoConfigurationTests {
private static final String REDIS_CONTAINER_CONNECTION_DETAILS = "org.springframework.boot.testcontainers.service.connection.redis."
+ "RedisContainerConnectionDetailsFactory$RedisContainerConnectionDetails";
@Test
void whenNoExistingBeansRegistersServiceConnection() {
try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) {
applicationContext.register(WithNoExtraAutoConfiguration.class, ContainerConfiguration.class);
new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext);
applicationContext.refresh();
RedisConnectionDetails connectionDetails = applicationContext.getBean(RedisConnectionDetails.class);
assertThat(connectionDetails.getClass().getName()).isEqualTo(REDIS_CONTAINER_CONNECTION_DETAILS);
}
}
@Test
void whenHasExistingAutoConfigurationRegistersReplacement() {
try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) {
applicationContext.register(WithRedisAutoConfiguration.class, ContainerConfiguration.class);
new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext);
applicationContext.refresh();
RedisConnectionDetails connectionDetails = applicationContext.getBean(RedisConnectionDetails.class);
assertThat(connectionDetails.getClass().getName()).isEqualTo(REDIS_CONTAINER_CONNECTION_DETAILS);
}
}
@Test
void whenHasUserConfigurationDoesNotRegisterReplacement() {
try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) {
applicationContext.register(UserConfiguration.class, WithRedisAutoConfiguration.class,
ContainerConfiguration.class);
new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext);
applicationContext.refresh();
RedisConnectionDetails connectionDetails = applicationContext.getBean(RedisConnectionDetails.class);
assertThat(Mockito.mockingDetails(connectionDetails).isMock()).isTrue();
}
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration(ServiceConnectionAutoConfiguration.class)
static class WithNoExtraAutoConfiguration {
}
@Configuration(proxyBeanMethods = false)
@ImportAutoConfiguration({ ServiceConnectionAutoConfiguration.class, RedisAutoConfiguration.class })
static class WithRedisAutoConfiguration {
}
@Configuration(proxyBeanMethods = false)
static class ContainerConfiguration {
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer();
}
}
@Configuration(proxyBeanMethods = false)
static class UserConfiguration {
@Bean
RedisConnectionDetails redisConnectionDetails() {
return mock(RedisConnectionDetails.class);
}
}
}

@ -0,0 +1,40 @@
/*
* 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 smoketest.session.redis;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.testsupport.testcontainers.RedisContainer;
import org.springframework.context.annotation.Bean;
@TestConfiguration(proxyBeanMethods = false)
public class TestSampleSessionRedisApplication {
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer();
}
public static void main(String[] args) {
SpringApplication.from(SampleSessionRedisApplication::main)
.with(TestSampleSessionRedisApplication.class)
.run(args);
}
}
Loading…
Cancel
Save