After a long time of blog hiatus, I was in the mood of trying out one of these “The best way to XYZ” posts for once.
While Spring Boot 3 and Spring Framework 6 releases have focused a lot on revamping the application context and annotation processing for GraalVM native image compatibility (and “boring” tasks like Java EE to Jakarta EE migrations), Spring Boot 3.1 and the corresponding framework edition come with a lot of cool changes.
While I was evaluating them for my team (and actually, for a new Spring Infographic coming out later this year), I especially dove into the new `@ServiceConnection` and the related infrastructure.
@ServiceConnection
comes together with a hierarchy of interfaces, starting at ConnectionDetails
. You might wonder what’s the fuss about that marker interface, especially when you come only from a relatively standardised JDBC abstraction: It makes it possible to abstract connections away from a second angle. Do configuration values come from property sources or something else? In that case, from information that Testcontainers provide. ConnectionDetails
is just the entry point to JdbcConnectionDetails
or other more specific ones, such as Neo4jConnectionDetails
. Below those, concrete classes exists to connect to services.
The nice thing is: You don’t have to deal with that a lot, because there are many existing implementations:
- Cassandra
- Couchbase
- Elasticsearc
- Generic JDBC or specialised JDBC such as MariaDB, MySQl, Oracle, PostgreSQL
- Kafka
- MongoDB
- Neo4j
- RabbitMQ
- Redpanda
More about those features in Spring Boot 3.1’s ConnectionDetails abstraction and Improved Testcontainers Support in Spring Boot 3.1.
We will focus on the latter. What has been pestering me for a while: If you use Testcontainers JUnit 5 extensions to integrate containers with Spring Boot test, you end up in a scenario in which two systems try to manage resources over a lifetime, which is not ideal.
If you have solved that, you must learn about @DynamicPropertySource
and friends, as demonstrated by Maciej nicely.
With some good combinations of @TestConfiguration
, the aforementioned new stuff and some good chunk of explicitness, this is a solved issue now.
Take this application as an example (I smashed everything in one class so that it is visible as a whole, not as a best practices, but well, I don’t care if you do program in production like this, I have seen worse):
import java.util.List;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@EnableNeo4jRepositories(considerNestedRepositories = true)
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
@Node
public record Movie(@Id @GeneratedValue(GeneratedValue.UUIDGenerator.class) String id, String title) {
Movie(String title) {
this(UUID.randomUUID().toString(), title);
}
}
interface MovieRepository extends Neo4jRepository<Movie, String> {
}
@RestController
static class MovieController {
private final MovieRepository movieRepository;
public MovieController(MovieRepository movieRepository) {
this.movieRepository = movieRepository;
}
@GetMapping("/movies")
public List<Movie> getMovies() {
return movieRepository.findAll();
}
}
} |
import java.util.List;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@EnableNeo4jRepositories(considerNestedRepositories = true)
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
@Node
public record Movie(@Id @GeneratedValue(GeneratedValue.UUIDGenerator.class) String id, String title) {
Movie(String title) {
this(UUID.randomUUID().toString(), title);
}
}
interface MovieRepository extends Neo4jRepository<Movie, String> {
}
@RestController
static class MovieController {
private final MovieRepository movieRepository;
public MovieController(MovieRepository movieRepository) {
this.movieRepository = movieRepository;
}
@GetMapping("/movies")
public List<Movie> getMovies() {
return movieRepository.findAll();
}
}
}
You will want the following dependencies in your test scope:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<scope>test</scope>
</dependency> |
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<scope>test</scope>
</dependency>
We use @TestConfiguration
to provide an additional test configuration. The @TestConfiguration
over @Configuration
is twofold: Unlike regular @Configuration
classes it does not prevent auto-detection of @SpringBootConfiguration
. And: It must be imported explicitly unless it is an inner static class to a test class. The code below than has one @Bean
method with @ServiceConnection
. The method returns a Neo4jContainer
Testcontainer. That container is marked as reusable. As we don’t close that resource by default, we let Testcontainers take care of cleaning it up. When marked as reusable, it will be kept alive and around, meaning a second test run will be much fast (See my video about that topic, if you don’t like it, there’s cycling and food in it as well and fwiw, I also put this into written words over at Foojay.io). The container also carries a special label, which is irrelevant for this config, but will be used later. We will then also address the @RestartScope
annotation.
This definition provides enough information for the context so that Spring can bring up that container, rewire all the connections for Neo4j to it and everything just works.
import java.util.Map;
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.Neo4jContainer;
@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfig {
@Bean
@ServiceConnection
@RestartScope
public Neo4jContainer<?> neo4jContainer() {
return new Neo4jContainer<>("neo4j:5")
.withLabels(Map.of("com.testcontainers.desktop.service", "neo4j"))
.withReuse(true);
}
} |
import java.util.Map;
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.Neo4jContainer;
@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfig {
@Bean
@ServiceConnection
@RestartScope
public Neo4jContainer<?> neo4jContainer() {
return new Neo4jContainer<>("neo4j:5")
.withLabels(Map.of("com.testcontainers.desktop.service", "neo4j"))
.withReuse(true);
}
}
Putting this into action might look like this. You might notice how to import the config and the absence of messing with properties.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@SpringBootTest
@Import(ContainerConfig.class)
class MyApplicationTests {
@Test
void repositoryIsConnectedAndUsable(
@Autowired MyApplication.MovieRepository movieRepository
) {
var movie = movieRepository.save(new MyApplication.Movie("Barbieheimer"));
assertThat(movie.id()).isNotNull();
}
} |
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@SpringBootTest
@Import(ContainerConfig.class)
class MyApplicationTests {
@Test
void repositoryIsConnectedAndUsable(
@Autowired MyApplication.MovieRepository movieRepository
) {
var movie = movieRepository.save(new MyApplication.Movie("Barbieheimer"));
assertThat(movie.id()).isNotNull();
}
}
To be fair, there are a bunch of other ways – also in the official docs, but I like this by far the best. It’s clear and concise, by being pretty explicit and sticking to one set of annotations (from Spring).
Now about @RestartScope
. That annotation is there fore a reason: You might have Springs devtools on the class path which will restart the context if necessary. When the context restarts, the container will restart, defeating the reusable flag. The annotation keeps the original bean around. Why is this relevant? The new Testcontainers support really works well with the concept of “developer services” as introduced by Quarkus. Originally, we only wanted to do test driven development, but than something happened, things getting rushed and in the end, explorative work is fun: So bringing up your application together with a running instance of a database or service feels a lot like “batteries included” and can make you very productive.
Spring Boot supports this now, too, but keeps it (by default) very explicit and also restricted to testing scope. org.springframework.boot.SpringApplication
has a new with
method used to augment an automatic configured application with additional config. We can use the above ContainerConfig
in an additional main-class living in our test scope like this:
import org.springframework.boot.SpringApplication;
public class MyApplicationWithDevServices {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main)
.with(ContainerConfig.class)
.run(args);
}
} |
import org.springframework.boot.SpringApplication;
public class MyApplicationWithDevServices {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main)
.with(ContainerConfig.class)
.run(args);
}
}
Starting this app, a request to http://localhost:8080/movies immediately works, connected against a Neo4j instance running in a container.
Now the best for last, what about that ominous label I added to the container? I am a happy Testcontainers Cloud user and I have their service running on my machine. This automatically redirects any Testcontainers container request to the cloud and I don’t need Docker on my machine.
There’s also the possibility to define fixed port-mappings for both containers running in the cloud and locally as described here Set fixed ports to easily debug development services.
I have the following configuration on my machine:
more /Users/msimons/.config/testcontainers/services/neo4j.toml
# This example selects neo4j instances and forwards port 7687 to 7687 on the client.
# Same for the Neo4j HTTP port
# Instances are found by selecting containers with label "com.testcontainers.desktop.service=neo4j".
# ports defines which ports to proxy.
# local-port indicates which port to listen on the client machine. System ports (0 to 1023) are not supported.
# container-port indicates which port to proxy. If unset, container-port will default to local-port.
ports = [
{local-port = 7687, container-port = 7687},
{local-port = 7474, container-port = 7474}
] |
more /Users/msimons/.config/testcontainers/services/neo4j.toml
# This example selects neo4j instances and forwards port 7687 to 7687 on the client.
# Same for the Neo4j HTTP port
# Instances are found by selecting containers with label "com.testcontainers.desktop.service=neo4j".
# ports defines which ports to proxy.
# local-port indicates which port to listen on the client machine. System ports (0 to 1023) are not supported.
# container-port indicates which port to proxy. If unset, container-port will default to local-port.
ports = [
{local-port = 7687, container-port = 7687},
{local-port = 7474, container-port = 7474}
]
This allows me to access the Neo4j instance started by MyApplicationWithDevServices
above under the well known Neo4j ports, allowing things like this:
# Use Cypher-Shell to create some data
cypher-shell -uneo4j -ppassword "CREATE (:Movie {id: randomUuid(), title: 'Dune 2'})"
# 0 rows
# ready to start consuming query after 15 ms, results consumed after another 0 ms
# Added 1 nodes, Set 2 properties, Added 1 labels
# Request the data from the application running with dev services
http localhost:8080/movies
# HTTP/1.1 200
# Connection: keep-alive
# Content-Type: application/json
# Date: Thu, 27 Jul 2023 13:32:58 GMT
# Keep-Alive: timeout=60
# Transfer-Encoding: chunked
#
# [
# {
# "id": "824ec97e-0a97-4516-8189-f0bf5eb215fe",
# "title": "Dune 2"
# }
# ] |
# Use Cypher-Shell to create some data
cypher-shell -uneo4j -ppassword "CREATE (:Movie {id: randomUuid(), title: 'Dune 2'})"
# 0 rows
# ready to start consuming query after 15 ms, results consumed after another 0 ms
# Added 1 nodes, Set 2 properties, Added 1 labels
# Request the data from the application running with dev services
http localhost:8080/movies
# HTTP/1.1 200
# Connection: keep-alive
# Content-Type: application/json
# Date: Thu, 27 Jul 2023 13:32:58 GMT
# Keep-Alive: timeout=60
# Transfer-Encoding: chunked
#
# [
# {
# "id": "824ec97e-0a97-4516-8189-f0bf5eb215fe",
# "title": "Dune 2"
# }
# ]
And with this, happy coding.
Update: I’m happy that Sergei personally read my post and rightfully noticed me that having fixed ports is possible with local and cloud Testcontainers. I edited the instructions accordingly. Thanks, buddy!
Filed in English posts, Java
|