Given all the moving parts and communication between these parts in microservices, contract tests provide valuable, fast feedback. Ideally, not only are all API methods covered, but also different rejection cases, such as bad requests or server errors. For this project, PACT is used as the contract testing framework. PACT is one of the most popular and mature contract testing frameworks which provides plug-ins and libraries for a range of languages.
Each consumer creates a contract with a provider. In this example, we are going to develop a contract for the deposit action between the Account Transactions consumer and the Account Command. The contract are stored as JSON files like the one below:
{
"state": "I have a picture that can be downloaded",
"uponReceiving": "a request to download some-file",
"withRequest": {
"method": "GET",
"path": "/download/somefile"
},
"willRespondWith": {
"status": 200,
"headers":
{
"Content-disposition": "attachment; filename=some-file.jpg"
}
}
}
To generate a contract, like the one above, we will need to implement a PACT fixture, build the contract, an implement a test which will generate this JSON.
To create a PACT fixture, we use PACT JVM Consumer for JUnit 5. This
library allows you to extend the test with the PactConsumerTestExt
extension and annotate the fixture with the provider name and optional port the provider binds to.
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "AccountCmd", port = "8082")
public class ConsumeDepositAccount {
...
}
To define the contract, we create a method annotated as a PACT, which provides the consumer and provider names. The contents of the method includes the use of a builder that helps to clearly define each property of the contract. The builder supports defining two major components. First, the input to send to the provider, and second, the results expected from the provider's response. The first, the input to send to the provider, and the second is the expected results.
@Pact(provider = "AccountCmd", consumer = "AccountTransactions")
public RequestResponsePact createGetOnePact(PactDslWithProvider builder)
throws JsonProcessingException {
return builder
.given("An account exists.")
.uponReceiving("Request to credit account.")
.path("/api/v1/accounts/credit")
.method("PUT")
.headers(getHeaders())
.body(objectMapper.writeValueAsString(new TransactionDto("5c892dbef72465ad7e7dde42", "5c892dbef72465ad7e7dde42", 10.0)))
.willRespondWith()
.status(200)
.body("{\"message\": \"SENT COMMAND\"}")
.toPact();
}
private Map<String, String> getHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
return headers;
}
Now that we have defined the PACT, we need to generate the JSON for publishing to the broker.
To generate the JSON contract, we need to define a test in our PACT fixture. The PACT test harness invokes the test, providing a mock service that behaves according to the defined contract. The test may communicate with the mock service and assert on expected behavior.
To be clear, this does not test the validity of the contract against the actual provider. The PACT library creates a mock server that is defined by the provided contract. Then we may use an HTTP client (here we use RestAssured) to test that we have correctly created the contract.
Once these tests successfully execute, the PACT library will generate the JSON contract. If this execution is performed by Gradle, then these contracts will be published to the ./test/pact directory
. If run through IntelliJ, then the contracts will be placed in the target directory of the consumer service.
@Test
void testGetOne(MockServer mockServer) throws IOException {
given()
.headers(getHeaders())
.body(objectMapper.writeValueAsString(new TransactionDto("5c892dbef72465ad7e7dde42", "5c892dbef72465ad7e7dde42", 10.0))).
when()
.put(mockServer.getUrl() + "/api/v1/accounts/credit").
then()
.statusCode(200);
}
Now that you have executed the test against the contract, you should have the following JSON contract.
{
"provider": {
"name": "AccountCmd"
},
"consumer": {
"name": "AccountTransactions"
},
"interactions": [
{
"description": "Request to credit account.",
"request": {
"method": "PUT",
"path": "/api/v1/accounts/credit",
"headers": {
"Content-Type": "application/json"
},
"body": {
"customerId": "5c892dbef72465ad7e7dde42",
"accountId": "5c892dbef72465ad7e7dde42",
"amount": 10.0
}
},
"response": {
"status": 200,
"body": {
"message": "SENT COMMAND"
}
},
"providerStates": [
{
"name": "An account exists."
}
]
}
],
"metadata": {
"pactSpecification": {
"version": "3.0.0"
},
"pact-jvm": {
"version": "3.6.2"
}
}
}
package com.ultimatesoftware.banking.account.transactions.tests.contracts.consumer;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.Pact;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.model.RequestResponsePact;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ultimatesoftware.banking.account.transactions.models.TransactionDto;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import static io.restassured.RestAssured.given;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "AccountCmd", port = "8082")
public class ConsumeDepositAccount {
private ObjectMapper objectMapper = new ObjectMapper();
@Pact(provider = "AccountCmd", consumer = "AccountTransactions")
public RequestResponsePact createGetOnePact(PactDslWithProvider builder)
throws JsonProcessingException {
return builder
.given("An account exists.")
.uponReceiving("Request to credit account.")
.path("/api/v1/accounts/credit")
.method("PUT")
.headers(getHeaders())
.body(objectMapper.writeValueAsString(new TransactionDto("5c892dbef72465ad7e7dde42", "5c892dbef72465ad7e7dde42", 10.0)))
.willRespondWith()
.status(200)
.body("{\"message\": \"SENT COMMAND\"}")
.toPact();
}
@Test
void testGetOne(MockServer mockServer) throws IOException {
given()
.headers(getHeaders())
.body(objectMapper.writeValueAsString(new TransactionDto("5c892dbef72465ad7e7dde42", "5c892dbef72465ad7e7dde42", 10.0))).
when()
.put(mockServer.getUrl() + "/api/v1/accounts/credit").
then()
.statusCode(200);
}
private Map<String, String> getHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
return headers;
}
}
The PACT Broker provides a repository for contracts and test results. Here, we will demo the contract repository functionality using the PACT JVM Provider Gradle plug-in.
Since we have already generated the JSON contracts, all we need to do now is submit them to the PACT Broker. We can do this using a simple HTTP POST. However, we will use the plug-in's pactPublish
task which has already been configured for use with the provided PACT Broker. Note, you will need to use Gradle to generate the PACTs since the publish step is configured to look for contracts within the ./tests/pact
directory. Use ./gradlew test --tests "*.contracts.consumer.*"
to generate the contracts using Gradle.
# Start the PACT Broker
docker-compose -f ./docker/pact-broker/docker-compose.yml up
Once the PACT broker is live, you should be able to visit it at http://localhost:8089
.
You should see the home page with no PACTs present.
To publish the PACTs use the following Gradle command either from IntelliJ or the terminal.
./gradlew pactPublish
This should output 201 Created responses from the broker.
Now the broker should have PACT contracts. If you click on either a provider or consumer, you should also see a nice dependency topography map.
Now, we need to execute the published contracts against the real providers. In order to run these tests efficiently, the providers are heavily stubbed or mocked. This works since we are not looking for state and logic around the state as much as we are looking to validate or map input to output status codes and structure; in other words, the contract.
We have already defined the providers using the Gradle plugin within ./build.gradle
under the task Pact, which looks like the following:
pact {
serviceProviders {
AccountCmd {
protocol = 'http'
host = 'localhost'
port = 8082
path = '/'
if ('pactVerify' in gradle.startParameter.taskNames) {
hasPactsFromPactBroker('http://localhost:8089')
}
}
}
}
Here the path to the PACT broker is provided as well as the location and port of the provider AccountCmd.
First, we need all provider APIs to be up. The Docker Compose configuration below will provide heavily stubbed/mocked APIs, allowing us to run the contract tests with minimal infrastructure.
docker-compose -f docker-compose-internal-mocked.yml up -d
Now, we can finally run the tests. The PACT Gradle plug-in also provides a method for executing these contracts through a task called pactVerify
.
This task uses the configuration above to determine what providers are present, where to submit the contract tests to, and where to find the PACT contracts.
./gradlew pactVerify
This command should complete successfully, outputting results for each test in a Given / When / Then syntax.
In this guide, we learned how to build PACT contracts, publish them to the PACT Broker, and execute consumer contract tests against the actual providers, where the provider is the sum of the consumer contracts against it.