How test engineers can use WireMock to substitute external services
It might be hard to find a fully independent service today. Even in the case of a monolithic architecture, where all components of the software are tightly coupled within a single codebase, there's still a need to integrate with the outer world. For instance, internal banking logic can be self-sufficient. But functionalities like police security checks and interbank money transfers can't be done in isolation.
During the development phase of a product or feature, programmers may write stubs and mocks to substitute missing dependencies. As QA engineers, however, we want to test the code that will go into production, touching the same statements and execution paths as the real data. Furthermore, we need to validate the system's behavior with various inputs, hence it should be possible to change how the external service responds to the program under test.
WireMock is a tool that enables us to do exactly this. It launches an HTTP server that can be configured to respond to specific requests in a desired manner. Let's say there is a system that should verify the recipient's IBAN against a database. If the response is positive, the transfer proceeds; if negative, the transfer must be blocked.
Consider two test cases, covering the anti-terrorism check (ATC).
Anti-terrorism check: must complete money transfer if positive
- Go to Transfers -> New Transfer.
- Set the recipient's IBAN to a value that will trigger a positive ATC response.
- Set the amount to 100 EUR.
- Confirm.
Expected result: the transfer is completed.
Anti-terrorism check: must block money transfer if negative
- Go to Transfers -> New Transfer.
- Set the recipient's IBAN to a value that will trigger a negative ATC response.
- Set the amount to 100 EUR.
- Confirm.
Expected result: the transfer is blocked.
To validate how our program behaves in both scenarios, we need a specific response from the ATC service. Unfortunately, this is not always feasible: a test instance with predefined IBAN-response pairs might not be available, these pairs might change over time, and break our tests. Besides, we should avoid calling real servers in automated tests to prevent unwanted load.
But if we know that our server makes a POST
request to https://api.police.gov/atc
with the following body:
{
"type": "IBAN",
"identifier": "NL43INGB3831267707"
}
We can configure WireMock to listen to POST /atc
calls and respond with positive:
{
"result": "POSITIVE"
}
And negative results:
{
"result": "NEGATIVE",
"score": 0.75
}
I suggest creating a simple banking application that will implement this logic. We can then add some tests and see how it all comes together.
Source code
The whole project can be found here.
Application
I decided to build the banking service using Spring Boot due to my familiarity with it. The complete code, including request and response models, is available here. It features a single controller designed to handle POST /operations/transfer
requests:
@RestController
@RequestMapping("/operations")
public class OperationsController {
@Value("${antiTerrorismCheckServiceUrl}")
private String antiTerrorismCheckServiceUrl;
@PostMapping(value = "/transfer")
public ResponseEntity<TransferResponse> transfer(@RequestBody TransferRequest transferRequest) {
var atcRequest = new AntiTerrorismCheckRequest(IBAN, transferRequest.recipientAccount());
var atcResponse = RestAssured.given()
.baseUri(antiTerrorismCheckServiceUrl)
.contentType(ContentType.JSON)
.body(atcRequest)
.post("/atc")
.as(AntiTerrorismCheckResponse.class);
if (atcResponse.result() == AntiTerrorismCheckResult.POSITIVE) {
return ResponseEntity.ok(new TransferResponse(true, null));
} else {
var message = format("Anti-terrorism check has failed with score=%.2f", atcResponse.score());
return ResponseEntity.ok(new TransferResponse(false, message));
}
}
}
Testing
In the introduction, we discussed one positive and one negative test. Since both are sending a money transfer request, to avoid code duplication, I defined two methods:
transferRequestBody()
to generate the request body.postOperationsTransfer(TransferRequest body)
to make the actual request and return the response.
private TransferRequest transferRequestBody() {
return new TransferRequest(
format("%s %s", randomAlphabetic(5), randomAlphabetic(10)),
// the account number is hardcoded
// as the goal of this article is not to generate
// random valid IBANs
"NL43INGB3831267707",
BigDecimal.valueOf(123.45),
randomAlphabetic(25));
}
private TransferResponse postOperationsTransfer(TransferRequest body) {
var baseUri = Optional.ofNullable(System.getenv("BANKING_SERVICE_URL")).orElse("http://localhost:8080");
return given()
.baseUri(baseUri)
.contentType(ContentType.JSON)
.body(body)
.post("/operations/transfer")
.then()
.statusCode(200)
.extract()
.as(TransferResponse.class);
}
We'll discuss the environment variable BANKING_SERVICE_URL
later. For now, this code is going to assume that the server is running at http://localhost:8080
, which is true for our Spring Boot app. Using these two methods, we can make API calls to http://localhost:8080/operations/transfer
with the following body:
{
"recipientName": "ywVvs BvyNcAtiex",
"recipientAccount": "NL43INGB3831267707",
"amount": 123.45,
"description": "TRFckRAkTLfaKqVnSNNUvBGnw"
}
Now that the test support code is in place, adding tests is straightforward:
@Test
public void transferPositive() {
var transferRequest = transferRequestBody();
var transferResponse = postOperationsTransfer(transferRequest);
assertTrue(transferResponse.completed());
assertNull(transferResponse.message());
}
@Test
public void transferNegative() {
var transferRequest = transferRequestBody();
var transferResponse = postOperationsTransfer(transferRequest);
assertFalse(transferResponse.completed());
assertEquals(transferResponse.message(), "Anti-terrorism check has failed with score=0.75");
}
Currently, we're generating the same transferRequest
and will receive the same transferResponse
if we execute the tests. Despite that, we expect different results:
transferResponse.completed()
should betrue
for the positive test andfalse
for the negative test.transferResponse.message()
should be null for the positive test and contain an error message for the negative test.
Inevitably, one of the tests will fail because the same input produces the same output. If the anti-terrorism check service is not configured, both tests will fail: the controller will return a 500 error in my implementation.
WireMock
To provide our banking server with a substitution for the ATC service, we'll use WireMock. There are various ways to launch it:
- Run it as a standalone program on your system or in a Docker container.
- Use annotations to automate the process with JUnit.
- Use plain Java.
It makes sense to start and stop WireMock with our tests, since it has no use for us outside of the test execution phase. As the official documentation describes how to work with JUnit well enough, I want to demonstrate how to do the same with TestNG.
First, let's create a parent class for tests that use WireMock. This class can be extended by such tests, inheriting the required behavior. We need a single class property to hold the reference to the WireMock server:
public abstract class AbstractWireMockTest {
private WireMockServer wireMockServer;
}
Using TestNG annotations, we can start the WireMock server before running any tests:
@BeforeSuite
public void beforeSuite() {
// start the server
wireMockServer = new WireMockServer(options().port(8090));
wireMockServer.start();
// <...>
}
We should also tell the WireMock client to use the same port to establish the connection between them:
@BeforeSuite
public void beforeSuite() {
// <...>
// configure the client to connect to the same port
WireMock.configureFor(8090);
}
TestNG will execute the beforeSuite()
method, which:
- Launches the WireMock server at
http://localhost:8090
. - Configures the WireMock client to work with the server at
http://localhost:8090
.
As we usually want our tests to be independent, there is a way to reset all stubs between tests like this:
@BeforeMethod
public void resetStubs() {
WireMock.reset();
}
Finally, we should stop the WireMock server after executing all tests:
@AfterSuite
public void afterSuite() {
// stop the server after all tests
wireMockServer.stop();
}
Now that the WireMock server and client are ready, we can start integrating them with our tests:
public class OperationsControllerTest extends AbstractWireMockTest {
// <...>
}
Consider the positive test. Our expectation is that the ATC service should return a positive response. Using the stubFor(...)
method, we can configure the WireMock server to respond to POST /atc
requests positively. Insert this code before making the API call:
var atcResponse = "{\"result\":\"POSITIVE\"}";
stubFor(post("/atc").willReturn(okJson(atcResponse)));
Additionally, we can verify that our banking server makes exactly one request to the anti-terrorism check service, and that it sends the correct type and identifier values:
var expectedJson = "{\"type\": \"IBAN\", \"identifier\": \"NL43INGB3831267707\"}";
verify(exactly(1), postRequestedFor(urlEqualTo("/atc")));
verify(postRequestedFor(urlEqualTo("/atc")).withRequestBody(equalToJson(expectedJson)));
Run this test, and it will pass. Then, try changing the account number in expectedJson
, and the test will fail at the second verification.
Let's also mock the ATC service for our negative test. In this case, it should return a negative response with some score value:
@Test
public void transferNegative() {
var atcResponse = "{\"result\":\"NEGATIVE\",\"score\":\"0.75\"}";
stubFor(post("/atc").willReturn(okJson(atcResponse)));
var transferRequest = transferRequestBody();
var transferResponse = postOperationsTransfer(transferRequest);
var expectedJson = "{\"type\": \"IBAN\", \"identifier\": \"NL43INGB3831267707\"}";
verify(exactly(1), postRequestedFor(urlEqualTo("/atc")));
verify(postRequestedFor(urlEqualTo("/atc")).withRequestBody(equalToJson(expectedJson)));
assertFalse(transferResponse.completed());
assertEquals(transferResponse.message(), "Anti-terrorism check has failed with score=0.75");
}
Essentially, by changing the mocked response (stored in atcResponse
), we alter the execution path of the system under test. This allows us to easily test behavior that depends on external services.
Two aspects I'd like to improve here are how we store the atcResponse
and expectedJson
. Currently, these values are hardcoded in string literals, making them a bit tricky to read and modify. One option is to store WireMock responses in the src/test/resources/__files
directory:
// src/test/resources/__files/responsePositive.json
{
"result": "POSITIVE"
}
// src/test/resources/__files/responseNegative.json
{
"result": "NEGATIVE",
"score": 0.75
}
And load the content using the withBodyFile
method:
var response = aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBodyFile("responsePositive.json");
// or for the negative test
//.withBodyFile("responseNegative.json");
stubFor(post("/atc").willReturn(response));
We can also utilize different WireMock matchers to verify the request coming from our banking app:
verify(postRequestedFor(urlEqualTo("/atc"))
.withRequestBody(matchingJsonPath("$.type", equalTo("IBAN")))
.withRequestBody(matchingJsonPath("$.identifier", equalTo(transferRequest.recipientAccount()))));
You can find the finalized test class here.
Docker
Dockerization of services and tests simplifies the development and deployment process for all involved parties. Therefore, I suggest becoming more practical and encapsulating everything we've created in containers.
We need the ability to build and run the service normally. Dockerfile and docker-compose.yml files are responsible for that.
Here are the key configuration bits:
version: "3.1"
services:
banking:
# <...>
networks:
- banking
environment:
- ATC_SERVICE_URL=https://api.police.gov
networks:
banking:
driver: bridge
We're passing the value for the ATC_SERVICE_URL
environment variable, which typically points to an API provided by the government. However, during testing, we will override this URL and force the service to interact with the WireMock server instead. The networks
block allows the banking service and tests to run within the same virtual network, enabling communication between them.
Separate Dockerfile.test and docker-compose.test.yml files are used to reconfigure the banking server and execute tests:
services:
banking:
environment:
- ATC_SERVICE_URL=http://testing-service:8090
testing-service:
container_name: banking-test
build:
context: .
dockerfile: Dockerfile.test
networks:
- banking
volumes:
- /home/aleksei/Documents/DockerVolume/:/src/testing/build/reports/
environment:
- BANKING_SERVICE_URL=http://banking-service:8080
We're overriding the ATC_SERVICE_URL
variable to direct the service to our testing container where the WireMock server runs. We're also setting the BANKING_SERVICE_URL
value to enable tests to connect to the banking app container. As you might recall from the code above, we use this value when making requests in our tests:
// here, with System.getenv("BANKING_SERVICE_URL")
var baseUri = Optional.ofNullable(System.getenv("BANKING_SERVICE_URL")).orElse("http://localhost:8080");
return given()
.baseUri(baseUri)
.contentType(ContentType.JSON)
.body(body)
.post("/operations/transfer")
.then()
.statusCode(200)
.extract()
.as(TransferResponse.class);
}
When running tests using Intellij IDEA or another IDE, http://localhost:8080
is used, due to the undefined BANKING_SERVICE_URL
value. However, when running tests in Docker, the value specified in docker-compose.test.yml
will be used: http://banking-service:8080
.
Finally, the volume maps Gradle test reports to the host machine. Knowing the test results is useful! So, please replace /home/aleksei/Documents/DockerVolume/
with a path on your file system.
With all this setup, we can start the server and tests using:
docker-compose -f docker-compose.yml -f docker-compose.test.yml up -d --build
After the build, both containers will start, with the tests concluding shortly afterward. Since we have only two tests, their execution will be quick. Reports should be available at the specified path.
File: DockerVolume/tests/test/classes/ee.fakeplastictrees.test.OperationsControllerTest.html
Conclusion
Hopefully, by now, you see how powerful and useful WireMock is. I encourage you to explore the official documentation to discover other features it offers. While I can't say that the documentation is perfect, it provides a solid starting point. Some details, such as available verifications, might be easier to find in the library's code itself.
Of course, mocking is not a new concept in the world of software testing. However, what sets WireMock and similar tools apart is their flexibility. The configuration can be changed quickly to suit our needs. Moreover, we don't need to code service substitutions ourselves, which saves a significant amount of time and allows us to focus on our actual objectives.
Finally, WireMock is equally beneficial for developers. We can share the same tool and potentially even code or mappings, as they also need to simulate external services during development.