Skip to main content

Spring Boot 3.1 introduced support for Testcontainers by adding the new ConnectionDetails and ServiceConnection abstractions. If you haven’t read about it yet, please check out Spring Boot Application Testing and Development with Testcontainers.

As of today, the following Testcontainers container abstractions are already supported as part of the Spring Boot 3.1 support.

Spring Boot 3.1.0:

  • CassandraContainer
  • CouchbaseContainer
  • ElasticsearchContainer
  • GenericContainer using redis or openzipkin/zipkin
  • JdbcDatabaseContainer
  • KafkaContainer
  • MongoDBContainer
  • MariaDBContainer
  • MSSQLServerContainer
  • MySQLContainer
  • Neo4jContainer
  • OracleContainer
  • PostgreSQLContainer
  • RabbitMQContainer
  • RedpandaContainer

Spring Boot 3.2.0:

  • GenericContainer using symptoma/activemq or otel/opentelemetry-collector-contrib
  • OracleContainer (oracle-free)
  • PulsarContainer

Read more about What’s new with Testcontainers in Spring Boot 3.2.0

Testcontainers allows you to write integration tests and be confident that the integration between the application in development and the service to interact with works as intended. WireMock helps by simulating API dependencies. An API dependency, in this context, refers to an external service that your application relies on for specific functionality or data, in our case we will have GitHub API as an API dependency. It is often not easily possible to run such dependencies by yourself. Nowadays, WireMock provides its own WireMock implementation.

Building your own Spring Boot auto-configuration is a common use case when you want to implement an integration that can be shared across teams. In the following example, we will write a simple auto-configuration to integrate with the GitHub GraphQL API.

First, let’s declare WireMock’s Testcontainers dependency

<dependency>
	<groupId>org.wiremock.integrations.testcontainers</groupId>
	<artifactId>wiremock-testcontainers-module</artifactId>
	<version>1.0-alpha-13</version>
</dependency>

Also, let’s add the maven-dependency-plugin

<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-dependency-plugin</artifactId>
	<executions>
		<execution>
			<id>copy</id>
			<phase>compile</phase>
			<goals>
				<goal>copy</goal>
			</goals>
			<configuration>
				<artifactItems>
					<artifactItem>
						<groupId>io.github.nilwurtz</groupId>
					<artifactId>wiremock-graphql-extension</artifactId>
						<version>0.7.1</version>
						<classifier>jar-with-dependencies</classifier>
					</artifactItem>
				</artifactItems>
				<outputDirectory>${project.build.directory}/test-wiremock-extension</outputDirectory>
			</configuration>
		</execution>
	</executions>
</plugin>

The plugin will download the WireMock GraphQL Extension, which will be used when writing tests.

Building the AutoConfiguration

Nowadays, the following approach is the recommended way to provide custom configuration options for your applications. @ConfigurationProperties will allow you to inject values for github.url and github.token via environment variables, system properties or properties in the application.properties or application.yaml files and hence make full use of Spring’s built-in configuration capabilities.

@ConfigurationProperties(prefix = "github")
public record GHProperties(String url, String token) {

}

The GHProperties bean can be injected wherever it is needed by using @EnableConfigurationProperties as you can see in the AutoConfiguration below.

@AutoConfiguration
@EnableConfigurationProperties(GHProperties.class)
public class GHAutoConfiguration {

	@Bean
	GraphQlClient ghGraphQlClient(GHProperties properties, WebClient.Builder webClientBuilder) {
		var githubBaseUrl = properties.url();
		var authorizationHeader = "Bearer %s".formatted(properties.token());
		return HttpGraphQlClient
						.builder(webClientBuilder.build())
						.url(githubBaseUrl + "/graphql")
						.header("Authorization", authorizationHeader)
						.build();
	}
}

Now, let’s register the AutoConfiguration in src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

com.example.testcontainerswiremockexample.GHAutoConfiguration

GHAutoConfiguration will provide the infrastructure and can be packaged and distributed independently. Then, the JAR can be used in any project and can be used as part of our application.

Using the AutoConfiguration

Let’s create a GHService class and use GraphQlClient, which now will be injected automatically thanks to the auto-configuration implemented previously.

@Service
public class GHService {

	private final GraphQlClient graphQlClient;

	public GHService(GraphQlClient ghGraphQlClient) {
		this.ghGraphQlClient = ghGraphQlClient;
	}

	public Mono<GitHubResponse> getStats(Map<String, Object> variables) {
		return this.graphQlClient.documentName("githubStats")
						.operationName("Stats")
						.variables(variables)
						.retrieve("repository")
						.toEntity(GitHubResponse.class);
	}

}

The GraphQL query will use the document githubStats.graphql and the operation will return a GitHubResponse entity

public record GitHubResponse(Issues issues, PullRequests pullRequests, Stargazers stargazers, Watchers watchers, Forks forks) {
	record Issues(int totalCount) {}

	record PullRequests(int totalCount) {}

	record Stargazers(int totalCount) {}

	record Watchers(int totalCount) {}

	record Forks(int totalCount) {}
}

Now, let’s add the content below in src/main/java/graphql-documents/githubStats.graphql

query Stats($owner: String!, $name: String!) {
    repository(owner: $owner, name: $name) {
        issues(states: OPEN) {
            totalCount
        }
        pullRequests(states: OPEN) {
            totalCount
        }
        stargazers {
            totalCount
        } watchers {
            totalCount
        } forks {
            totalCount
        }
    }
}

Testing with @DynamicPropertySource

Thanks to @DynamicPropertySource, we can specify the properties defined in GHProperties at runtime, so the tests will contain the proper configuration to connect to a WireMock instance running via Testcontainers.

@SpringBootTest
@Testcontainers
class TestcontainersWiremockExampleApplicationTests {

	private static final Logger LOGGER = LoggerFactory.getLogger("wiremock");

	@Container
	static WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.2.0-alpine")
			.withMapping("graphql", TestcontainersWiremockExampleApplicationTests.class, "graphql-resource.json")
			.withExtensions("graphql",
					List.of("io.github.nilwurtz.GraphqlBodyMatcher"),
					List.of(Paths.get("target", "test-wiremock-extension", "wiremock-graphql-extension-0.7.1-jar-with-dependencies.jar").toFile()))
			.withFileFromResource("testcontainers-java.json", TestcontainersWiremockExampleApplicationTests.class, "testcontainers-java.json")
			.withLogConsumer(new Slf4jLogConsumer(LOGGER));

	@DynamicPropertySource
	static void properties(DynamicPropertyRegistry registry) {
		registry.add("github.url", wireMock::getBaseUrl);
		registry.add("github.token", () -> "test");
	}

	@Autowired
	private GHService ghService;

	@Test
	void contextLoads() {
		var variables = Map.<String, Object>of("owner", "testcontainers", "name", "testcontainers-java");
		StepVerifier.create(this.ghService.getStats(variables))
				.expectNext(new GitHubResponse(
						new GitHubResponse.Issues(385),
						new GitHubResponse.PullRequests(90),
						new GitHubResponse.Stargazers(6560),
						new GitHubResponse.Watchers(142),
						new GitHubResponse.Forks(1295)))
				.verifyComplete();
	}

}

The test defines the WireMockContainer along with the mapping, the extension and the response. It also configures the log in order to get feedback from WireMock in case the request doesn’t match.

Some additional resources are also needed for our integration tests:

src/test/resources/com/example/testcontainerswiremockexamples/TestcontainersWiremockExampleApplicationTests/graphql-resource.json

{
  "request": {
    "method": "POST",
    "url": "/graphql",
    "headers": {
      "Authorization": {
        "contains": "Bearer"
      }
    },
    "bodyPatterns": [
      {
        "equalToJson": "{\"query\":\"query Stats($owner: String!, $name: String!) {\\n    repository(owner: $owner, name: $name) {\\n        issues(states: OPEN) {\\n            totalCount\\n        }\\n        pullRequests(states: OPEN) {\\n            totalCount\\n        }\\n        stargazers {\\n            totalCount\\n        } watchers {\\n            totalCount\\n        } forks {\\n            totalCount\\n        }\\n    }\\n}\", \"operationName\": \"Stats\", \"variables\":{\"owner\":\"testcontainers\",\"name\":\"testcontainers-java\"}}"
      }
    ]
  },
  "response": {
    "status": 200,
    "bodyFileName": "testcontainers-java.json",
    "headers": {
      "Content-Type": "application/json"
    }
  }
}

src/test/resources/com/example/testcontainerswiremockexamples/TestcontainersWiremockExampleApplicationTests/testcontainers-java.json

{
    "data": {
        "repository": {
            "issues": {
                "totalCount": 385
            },
            "pullRequests": {
                "totalCount": 90
            },
            "stargazers": {
                "totalCount": 6560
            },
            "watchers": {
                "totalCount": 142
            },
            "forks": {
                "totalCount": 1295
            }
        }
    }
}

Adding ConnectionDetails support

Let’s modernize our auto-configuration by adding ConnectionDetails. In this case, the same parameters as in GHProperties are being exposed because those are needed to connect with the service we are simulating with WireMock.

public interface GHConnectionDetails extends ConnectionDetails {

	String url();

	String token();

}

GHConnectionsDetails will provide two implementations; the first will still rely on properties and the second will be shown later.

class PropertiesGHConnectionDetails implements GHConnectionDetails {

	private final GHProperties properties;

	public PropertiesGHConnectionDetails(GHProperties properties) {
		this.properties = properties;
	}

	@Override
	public String url() {
		return this.properties.url();
	}

	@Override
	public String token() {
		return this.properties.token();
	}

}

Then, as part of the auto-configuration, a GHConnectionDetails bean will be created when no other bean is provided. PropertiesGHConnectionDetails will be the default implementation in case no other is provided.

@AutoConfiguration
@EnableConfigurationProperties(GHProperties.class)
public class GHAutoConfiguration {

	@Bean
	@ConditionalOnMissingBean(GHConnectionDetails.class)
	GHConnectionDetails ghConnectionDetails(GHProperties properties) {
		return new PropertiesGHConnectionDetails(properties);
	}

	@Bean
	GraphQlClient ghGraphQlClient(GHConnectionDetails connectionDetails, WebClient.Builder webClientBuilder) {
		var githubBaseUrl = connectionDetails.url();
		var authorizationHeader = "Bearer %s".formatted(connectionDetails.token());
		return HttpGraphQlClient
						.builder(webClientBuilder.build())
						.url(githubBaseUrl + "/graphql")
						.header("Authorization", authorizationHeader)
						.build();
	}
}

Adding WireMock Testcontainers for Spring Boot’s ServiceConnection

Before continuing, let’s declare spring-boot-testcontainers as a dependency.

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-testcontainers</artifactId>
</dependency>

At this point, there is only one implementation so far which is PropertiesGHConnectionDetails. But, let’s add a second implementation for GHConnectionDetails, which will rely on WireMockContainer.

class WireMockContainerConnectionDetailsFactory extends ContainerConnectionDetailsFactory<WireMockContainer, GHConnectionDetails> {
	WireMockContainerConnectionDetailsFactory() {
	}

	protected GHConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<WireMockContainer> source) {
		return new WireMockContainerConnectionDetails(source);
	}

	private static final class WireMockContainerConnectionDetails extends ContainerConnectionDetailsFactory.ContainerConnectionDetails<WireMockContainer> implements GHConnectionDetails {
		private WireMockContainerConnectionDetails(ContainerConnectionSource<WireMockContainer> source) {
			super(source);
		}


		@Override
		public String url() {
			return getContainer().getBaseUrl();
		}

		@Override
		public String token() {
			return "test-token";
		}
	}
}

Let’s register WireMockContainerConnectionDetailsFactory in src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
com.example.testcontainerswiremockexample.WireMockContainerConnectionDetailsFactory

Using WireMock Testcontainers ServiceConnection

Now, we can remove the manual mapping of properties for the WireMockContainer and rely directly on the @ServiceConnection 

@SpringBootTest
@Testcontainers
class TestcontainersWiremockExampleApplicationTests {

	private static final Logger LOGGER = LoggerFactory.getLogger("wiremock");

	@Container
	@ServiceConnection
	static WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.2.0-alpine")
			.withMapping("graphql", TestcontainersWiremockExampleApplicationTests.class, "graphql-resource.json")
			.withExtensions("graphql",
					List.of("io.github.nilwurtz.GraphqlBodyMatcher"),
					List.of(Paths.get("target", "test-wiremock-extension", "wiremock-graphql-extension-0.7.1-jar-with-dependencies.jar").toFile()))
			.withFileFromResource("testcontainers-java.json", TestcontainersWiremockExampleApplicationTests.class, "testcontainers-java.json")
			.withLogConsumer(new Slf4jLogConsumer(LOGGER));

	@Autowired
	private GHService ghService;

	@Test
	void contextLoads() {
		var variables = Map.<String, Object>of("owner", "testcontainers", "name", "testcontainers-java");
		StepVerifier.create(this.ghService.getStats(variables))
				.expectNext(new GitHubResponse(
						new GitHubResponse.Issues(385),
						new GitHubResponse.PullRequests(90),
						new GitHubResponse.Stargazers(6560),
						new GitHubResponse.Watchers(142),
						new GitHubResponse.Forks(1295)))
				.verifyComplete();
	}

}

And just like that, adding support to connect with those external services during testing or development can be achieved by Testcontainers.

You can find the source code here.

Conclusion

Using Testcontainers and WireMock for simulating API behavior during testing and development is a powerful and efficient approach that offers numerous benefits to software development teams. This combination enables engineering teams to create a controlled and isolated testing environment that closely mirrors the real-world API interactions, fostering robust and reliable testing practices. And, with Spring Boot’s ConnectionDetails and ServiceConnection abstractions the Testcontainers integration and the Developer Experience would be seamless.

Eddú Meléndez

Software Engineer at AtomicJar. Working on Testcontainers for Java. In 2022 became a Java Champion.