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:

  1. It requires MAVEN_HOME (or ${maven.home} property) to be set.
  2. 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.
  3. Maven’s caching is not as good a Gradle’s.
  4. 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 using Network here and accessing PostgreSQL by its alias (db) instead of using PostgreSQLContainer#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.

comments powered by Disqus