E2E test your Spring Boot 2.3.0 apps with Testcontainers
Spring Boot 2.3.0.M1 comes with a number of great features.
I find one of them especially interesting -
now you can build layered Docker images using Cloud Native Buildpacks!
It is a very fast way of building Docker images compared to the Dockerfile approach thanks to Buildpacks.
It is so fast that we can use it… to build, start and test our service as a Docker container (with Testcontainers, of course!)
Building an image from tests
Let’s say I have a Spring Boot 2.3.0 service that I generated from https://start.spring.io with the following Gradle file:
plugins {
id 'org.springframework.boot' version '2.3.0.M1'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}
test.useJUnitPlatform()
// Make it possible to override the image name externally
bootBuildImage.imageName = project.properties["dockerImageName"]
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
// Testcontainers
testImplementation 'org.testcontainers:junit-jupiter:1.12.5'
// Add Gradle's TestKit as a dependency so that we can invoke it from our tests
testImplementation gradleTestKit()
}
I can easily build a Docker image for my service just by running the following command:
$ ./gradlew bootBuildImage
> Task :bootBuildImage
Building image 'docker.io/library/demo:latest'
(some buildpacks output)
Successfully built image 'docker.io/library/demo:latest'
One Gradle command and we get an image. Nice, huh?
The question is: can we run this command from our tests? Sure we can!
Since we need to build it once, I will be using LazyFuture
from Testcontainers:
private static final Future<String> IMAGE_FUTURE = new LazyFuture<>() {
@Override
protected String resolve() {
// Find project's root dir
File cwd;
for (
cwd = new File(".");
!new File(cwd, "settings.gradle").isFile();
cwd = cwd.getParentFile()
);
// Make it unique per folder (for caching)
var imageName = String.format(
"local/app-%s:%s",
DigestUtils.md5DigestAsHex(cwd.getAbsolutePath().getBytes()),
System.currentTimeMillis()
);
// Run Gradle task and override the image name
GradleRunner.create()
.withProjectDir(cwd)
.withArguments("-q", "bootBuildImage", "-PdockerImageName=" + imageName)
.forwardOutput()
.build();
return imageName;
}
};
Here we find the project’s root dir, generate a unique image name for it, and run the same Gradle task but with dockerImageName
property.
Building with Maven
You can also do the same with Maven but it requires a bit more code and configuration.
First, you need to make the image name configurable:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>${spring-boot.build-image.name}</name>
</image>
</configuration>
</plugin>
Now we need to call it from our tests.
In Maven, there is org.apache.maven.shared:maven-invoker
to invoke Maven goals programmatically.
We can use it to implement a similar approach:
var properties = new Properties();
properties.put("spring-boot.build-image.name", imageName);
// Avoid recursion :D
properties.put("skipTests", "true");
var request = new DefaultInvocationRequest()
.setPomFile(new File(cwd, "pom.xml"))
.setGoals(Arrays.asList("spring-boot:build-image"))
.setProperties(properties);
var invocationResult = new DefaultInvoker().execute(request);
if (invocationResult.getExitCode() != 0) {
throw new RuntimeException(invocationResult.getExecutionException());
}
Some things to keep in mind:
- It requires
MAVEN_HOME
(or${maven.home}
property) to be set. - It looks like the actual invocation is happening via the Process API, and will JVM set in JAVA_HOME, which may differ from the one you use to run the tests.
- Maven’s caching is not as good a Gradle’s.
- Maven Wrapper (which is IMO a must have) needs to be handled manually.
Starting our app inside a container
Now I have a Future
that resolves to the Docker image name of my app, neat!
We can now pass this Future
to GenericContainer
and start it (here I am using Testcontainers’ JUnit 5 support):
@Testcontainers
class DemoApplicationTests {
static final Future<String> IMAGE_FUTURE = new LazyFuture<>() { ... }
@Container
static final GenericContainer<?> APP = new GenericContainer<>(IMAGE_FUTURE)
.withExposedPorts(8080);
WebTestClient webClient;
@BeforeEach
void setUp() {
var endpoint = String.format(
"http://%s:%d/",
APP.getContainerIpAddress(),
APP.getFirstMappedPort()
);
webClient = WebTestClient.bindToServer().baseUrl(endpoint).build();
}
@Test
public void starts() {
}
@Test
public void healthy() {
webClient.get()
.uri("/actuator/health")
.exchange()
.expectStatus()
.is2xxSuccessful();
}
}
As you can see, we can’t inject WebTestClient
anymore (in fact, we’re not using @SpringBootTest
annotation at all!).
Instead, we create our own instance of it and point to the app’s container. Once done, we can use it as always.
Improving UX
Since we’re running the app containerized, we can’t see the logs anymore! But don’t worry, we can forward them, since Testcontainers supports log consumers:
new GenericContainer<>(IMAGE_FUTURE)
.withExposedPorts(8080)
// Forward logs
.withLogConsumer(new ToStringConsumer() {
@Override
public void accept(OutputFrame outputFrame) {
if (outputFrame.getBytes() != null) {
try {
System.out.write(outputFrame.getBytes());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
});
Should we need to tune some parameters (with the same mechanism we’d be using in production!), we can set environment variables:
.withExposedPorts(9090)
.withEnv("SERVER_PORT", "9090")
Need to connect it to DB? Easy!
Add org.testcontainers:postgresql
module, and then:
@Container
static final PostgreSQLContainer<?> postgresql = new PostgreSQLContainer<>()
.withNetwork(Network.SHARED)
.withNetworkAliases("db");
@Container
static final GenericContainer<?> APP = new GenericContainer<>(IMAGE_FUTURE)
.withNetwork(Network.SHARED)
.dependsOn(postgresql)
.withEnv(
"SPRING_DATASOURCE_URL",
"jdbc:postgresql://db:5432/test?user=test&password=test"
)
⚠️ WARNING!
Note that I am usingNetwork
here and accessing PostgreSQL by its alias (db
) instead of usingPostgreSQLContainer#getJdbcUrl
.
Because we’re using GenericContainer
, everything we can do with it we can also apply to our app running inside a container.
And Testcontainers provides a lot of common functionality.
You can even debug it with the approach described in How to locally debug containers started by Testcontainers!
Conclusion
Although I still prefer @SpringBootTest
, the Buildpack support brings a whole new level of testing
and gets us even closer to production, since we’re using exactly the same Docker image / classpath as we do in prod.