mn create-app example.micronaut.micronautguide \
--features=http-client,micronaut-test-rest-assured,testcontainers \
--build=maven \
--lang=java \
--test=junit
Table of Contents
- 1. Getting Started
- 2. What you will need
- 3. Solution
- 4. What we are going to achieve in this guide
- 5. Writing the Application
- 6. About the application
- 7. Create Album and Photo models
- 8. Create PhotoServiceClient
- 9. Implement API endpoint to get an album by id
- 10. Testing
- 11. Testing the Application
- 12. Summary
- 13. Next Steps
- 14. Help with the Micronaut Framework
- 15. License
Testing REST API integrations using Testcontainers with WireMock or MockServer
This guide shows how to test an external API integration using Testcontainers WireMock module.
Authors: Sergio del Amo
Micronaut Version: 4.6.3
1. Getting Started
In this guide, you will learn how to
-
Create a Micronaut application that talks to an external REST API.
-
Test the external API integration using Testcontainers WireMock module.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 21 or greater installed with
JAVA_HOME
configured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. What we are going to achieve in this guide
We will create a Micronaut application that talks to an external REST API.
Then, we will test the external REST API integration using both the Testcontainers WireMock module and MockServer.
5. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
If you use Micronaut Launch, select Micronaut Application as application type and add http-client
, micronaut-test-rest-assured
, and testcontainers
features.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
6. About the application
Assume we are building an application to manage video albums, and we will use a 3rd party REST API to manage the image and video assets. For this guide, we will use a publicly available REST API jsonplaceholder.typicode.com as a 3rd party photo-service to store album photos.
We will implement a REST API endpoint to fetch an album for the given albumId. This API internally talks to the photo-service to fetch the photos for that album.
We will use WireMock, which is a tool for building mock APIs, to mock the external service interactions and test our API endpoints. Testcontainers provides the Testcontainers WireMock module so that we can run WireMock as a Docker container.
7. Create Album and Photo models
First, create Album and Photo models using Java records.
package example.micronaut;
import io.micronaut.serde.annotation.Serdeable;
@Serdeable (1)
public record Photo(Long id, String title, String url, String thumbnailUrl) {
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
package example.micronaut;
import io.micronaut.serde.annotation.Serdeable;
import java.util.List;
@Serdeable (1)
public record Album(Long albumId, List<Photo> photos) {
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
8. Create PhotoServiceClient
Let’s create PhotoServiceClient, which is a Micronaut declarative HTTP Client, to fetch photos for a given albumId.
package example.micronaut;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.client.annotation.Client;
import java.util.List;
@Client(id = "photosapi")
interface PhotoServiceClient {
@Get("/albums/{albumId}/photos")
List<Photo> getPhotos(@PathVariable Long albumId);
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
We have externalized the photo-service base URL as a configurable property. So, let us add the following property in the src/main/resources/application.properties file.
micronaut.http.services.photosapi.url=https://jsonplaceholder.typicode.com
9. Implement API endpoint to get an album by id
Let us implement a REST API endpoint to return an Album for the given albumId as follows:
package example.micronaut;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.scheduling.annotation.ExecuteOn;
import static io.micronaut.scheduling.TaskExecutors.BLOCKING;
@Controller("/api") (1)
class AlbumController {
private final PhotoServiceClient photoServiceClient;
AlbumController(PhotoServiceClient photoServiceClient) { (2)
this.photoServiceClient = photoServiceClient;
}
@ExecuteOn(BLOCKING) (3)
@Get("/albums/{albumId}") (4)
public Album getAlbumById(@PathVariable Long albumId) { (5)
return new Album(albumId, photoServiceClient.getPhotos(albumId));
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /api . |
2 | Use constructor injection to inject a bean of type PhotoServiceClient . |
3 | It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop. |
4 | The @Get annotation maps the getAlbumById method to an HTTP GET request on /albums/{albumId} . |
5 | You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable . |
Our application is exposing a REST API endpoint GET /api/albums/{albumId}
which internally makes an API call to https://jsonplaceholder.typicode.com/albums/{albumId}/photos
to get photos of that album, and it returns a response similar to the following:
{
"albumId": 1,
"photos": [
{
"id": 51,
"title": "non sunt voluptatem placeat consequuntur rem incidunt",
"url": "https://via.placeholder.com/600/8e973b",
"thumbnailUrl": "https://via.placeholder.com/150/8e973b"
},
{
"id": 52,
"title": "eveniet pariatur quia nobis reiciendis laboriosam ea",
"url": "https://via.placeholder.com/600/121fa4",
"thumbnailUrl": "https://via.placeholder.com/150/121fa4"
},
...
...
]
}
You can run the application and access http://localhost:8080/api/albums/1 to see the JSON response.
Let us see how we can test the photo-service API integration using WireMock.
10. Testing
10.1. Write a test for Photo API integration
It is better to mock the external API interactions at the HTTP protocol level instead of mocking the photoServiceClient.getPhotos(albumId) method because you will be able to verify any marshaling/unmarshalling errors, simulating network latency issues, etc.
Add the WireMock Standalone dependency to your project:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.0.4</version>
<scope>test</scope>
</dependency>
Add the repository https://jitpack.io to your build file to resolve the previous dependency.
|
Let us write the test for our GET /api/albums/{albumId}
API endpoint as follows:
package example.micronaut;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.micronaut.context.ApplicationContext;
import io.micronaut.http.MediaType;
import io.micronaut.runtime.server.EmbeddedServer;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Collections;
import java.util.Map;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
@Testcontainers(disabledWithoutDocker = true) (1)
class AlbumControllerTest {
@RegisterExtension (2)
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
private Map<String, Object> getProperties() {
return Collections.singletonMap("micronaut.http.services.photosapi.url", (3)
wireMock.baseUrl());
}
@Test
void shouldGetAlbumById() { (4)
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort(); (5)
Long albumId = 1L;
wireMock.stubFor( (6)
WireMock.get(urlMatching("/albums/" + albumId + "/photos"))
.willReturn(
aResponse()
.withHeader("Content-Type", MediaType.APPLICATION_JSON)
.withBody(
"""
[
{
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
},
{
"id": 2,
"title": "reprehenderit est deserunt velit ipsam",
"url": "https://via.placeholder.com/600/771796",
"thumbnailUrl": "https://via.placeholder.com/150/771796"
}
]
""")));
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
@Test (7)
void shouldReturnServerErrorWhenPhotoServiceCallFailed() {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort(); (5)
Long albumId = 2L;
wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos"))
.willReturn(aResponse().withStatus(500)));
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(500);
}
}
}
1 | Disable test if Docker not present. |
2 | We can create an instance of WireMock server using WireMockExtension . |
3 | We have registered the "micronaut.http.services.photosapi.url" property pointing to WireMock endpoint URL. |
4 | In the shouldGetAlbumById() test, we have set the expected mock response for /albums/{albumId}/photos API call and make a request to our application endpoint /api/albums/{albumId} and verified the response. |
5 | We are using the RestAssured library to test our API endpoint, so we captured the random port on which the application started and initialized RestAssured port. |
6 | Set the expectations for an API call. |
7 | In the shouldReturnServerErrorWhenPhotoServiceCallFailed() test, we have set the expected mock response for /albums/{albumId}/photos API call to return InternalServerError status code 500 and make a request to our application endpoint /api/albums/{albumId} and verified the response. |
10.2. Stubbing using JSON mapping files
Add the Testcontainers Java modules for WireMock dependency to your project:
<dependency>
<groupId>com.github.wiremock</groupId>
<artifactId>wiremock-testcontainers-java</artifactId>
<version>1.0-alpha-6</version>
<scope>test</scope>
</dependency>
In the previous test, we saw how to stub an API using wireMock.stubFor(…). Instead of stubbing using WireMock Java API, we can use JSON mapping-based configuration.
Create src/test/resources/wiremock/mappings/get-album-photos.json file as follows:
{
"mappings": [
{
"request": {
"method": "GET",
"urlPattern": "/albums/([0-9]+)/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "album-photos-resp-200.json"
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/2/photos"
},
"response": {
"status": 500,
"headers": {
"Content-Type": "application/json"
}
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/3/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
}
}
]
}
Now you can initialize WireMock by loading the stub mappings from mapping files as follows:
@RegisterExtension
static WireMockExtension wireMockServer = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().usingFilesUnderClasspath("wiremock"))
.build();
With mapping files-based stubbing in place, you can write tests as follows:
@Test
void shouldGetAlbumById() {
Long albumId = 1L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
10.3. Using Testcontainers WireMock Module
The Testcontainers WireMock module allows provisioning the WireMock server as a standalone container within your tests, based on WireMock Docker.
Create AlbumControllerTestcontainersTests and use WireMockContainer to initialize a wiremock server and stubbing as follows:
package example.micronaut;
import io.micronaut.context.ApplicationContext;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.runtime.server.EmbeddedServer;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.wiremock.integrations.testcontainers.WireMockContainer;
import java.util.Collections;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
@Testcontainers(disabledWithoutDocker = true) (1)
class AlbumControllerTestcontainersTests {
@Container (2)
static WireMockContainer wiremockServer = new WireMockContainer("wiremock/wiremock:2.35.0")
.withMapping("photos-by-album", AlbumControllerTestcontainersTests.class, "mocks-config.json") (3)
.withFileFromResource(
"album-photos-response.json",
AlbumControllerTestcontainersTests.class,
"album-photos-response.json");
@NonNull
public Map<String, Object> getProperties() { (4)
return Collections.singletonMap("micronaut.http.services.photosapi.url", (5)
wiremockServer.getBaseUrl());
}
@Test
void shouldGetAlbumById() {
Long albumId = 1L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
@Test
void shouldReturnServerErrorWhenPhotoServiceCallFailed() {
Long albumId = 2L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(500);
}
}
@Test
void shouldReturnEmptyPhotos() {
Long albumId = 3L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", nullValue());
}
}
}
1 | Disable test if Docker not present. |
2 | We are using Testcontainers JUnit 5 Extension annotations @Container to initialize WireMockContainer. |
3 | We have configured to load stub mappings from mocks-config.json file |
Create src/test/resources/example/micronaut/AlbumControllerTestcontainersTests/mocks-config.json file as follows:
{
"mappings": [
{
"request": {
"method": "GET",
"urlPattern": "/albums/([0-9]+)/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "album-photos-response.json"
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/2/photos"
},
"response": {
"status": 500,
"headers": {
"Content-Type": "application/json"
}
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/3/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
}
}
]
}
If you run the test, the call to photo API will receive the response using WireMock stubbings defined in mocks-config.json file.
10.4. Testing with MockServer
For any system you integrate with via HTTP or HTTPS MockServer can be used as a mock configured to return specific responses for different requests, a proxy recording and optionally modifying requests and responses, both a proxy for some requests and a mock for other requests at the same time.
10.5. MockServer dependencies
Add the Testcontainers MockServer dependency:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mockserver</artifactId>
<scope>test</scope>
</dependency>
Add the MockServer Java Client dependency:
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-client-java</artifactId>
<version>5.15.0</version>
<scope>test</scope>
</dependency>
10.5.1. MockServer Test
You can write a test using MockServer as follows:
package example.micronaut;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
import static org.mockserver.model.JsonBody.json;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.mockserver.client.MockServerClient;
import org.mockserver.model.Header;
import org.mockserver.verify.VerificationTimes;
import org.testcontainers.containers.MockServerContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.util.Collections;
import java.util.Map;
@MicronautTest (1)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (2)
@Testcontainers(disabledWithoutDocker = true) (3)
class AlbumControllerMockServerTest implements TestPropertyProvider { (4)
@Container
static MockServerContainer mockServerContainer = new MockServerContainer(
DockerImageName.parse("mockserver/mockserver:5.15.0")
);
static MockServerClient mockServerClient;
@Override
public @NonNull Map<String, String> getProperties() { (4)
mockServerContainer.start();
mockServerClient =
new MockServerClient(
mockServerContainer.getHost(),
mockServerContainer.getServerPort()
);
return Collections.singletonMap("micronaut.http.services.photosapi.url", (5)
mockServerContainer.getEndpoint());
}
@BeforeEach
void setUp() {
mockServerClient.reset();
}
@Test
void shouldGetAlbumById(RequestSpecification spec) { (6)
Long albumId = 1L;
mockServerClient
.when(request().withMethod("GET").withPath("/albums/" + albumId + "/photos"))
.respond(
response()
.withStatusCode(200)
.withHeaders(new Header("Content-Type", "application/json; charset=utf-8"))
.withBody(
json(
"""
[
{
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
},
{
"id": 2,
"title": "reprehenderit est deserunt velit ipsam",
"url": "https://via.placeholder.com/600/771796",
"thumbnailUrl": "https://via.placeholder.com/150/771796"
}
]
"""
)
)
);
spec (7)
.contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
verifyMockServerRequest("GET", "/albums/" + albumId + "/photos", 1);
}
private void verifyMockServerRequest(String method, String path, int times) {
mockServerClient.verify(
request().withMethod(method).withPath(path),
VerificationTimes.exactly(times)
);
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Disable test if Docker not present. |
4 | When you need dynamic properties definition, implement the TestPropertyProvider interface. Override the method .getProperties() and return the properties you want to expose to the application. |
5 | We have registered the "micronaut.http.services.photosapi.url" property pointing to MockServer container endpoint. |
6 | Inject an instance of RequestSpecification . |
7 | Micronaut Test sets the embedded server port on spec , so it’s unnecessary to inject EmbeddedServer and retrieve it explicitly. |
11. Testing the Application
To run the tests:
./mvnw test
Now, if you run your test, you should see in the console log that WireMock Docker instance is started which will act as the photo-service, serving the mock responses as per the configured expectations, and the test should pass.
12. Summary
We have learned how to integrate 3rd party HTTP APIs in a Micronaut application and test it using Testcontainers WireMock module or MockServer.
13. Next Steps
Refer to Testcontainers WireMock module’s documentation for more information.
Learn more about Micronaut Test and Testcontainers.
14. Help with the Micronaut Framework
The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.
15. License
All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |