Why Contract Testing?
In a previous article, we went over the need for testing the contract of communications between services when working in a distributed systems architecture. Today, we are going to dig deep into the practicality of putting this concept into action using PACT.
Why PACT?
After doing some research about what is there in the market, PACT was one of the best options since it provides a well-structured process of defining, managing, and validating contract tests. They provide us with a broker to store and version the pacts, which can be a powerful tool to monitor the state of the communication of your tool and even prevent the release of code that breaks the stated contracts.
Also, PACT works with a variety of programming languages and supports most of the famous frameworks, such as SpringBoot and .NET,
Ingredients... ๐งโ๐ณ
First, we will need some starter code. For the sake of this demo, I am going to create two projects: a provider and a consumer. Our domain will be "spells from Harry Potter" (yes, I am a big fan). So the provider acts like a server where we can fetch the list of available spells and add to them, while the consumer is a simple console application that uses these APIs. You can find the code for this stage of the demo here.
Time to Get Down To Business
Now that we have our starter code ready, it's time to start writing some contracts.
Setting Up The Needed Infrastructure
First, we will need to set up the PACT Broker container, so we will need the following configurations (which can be added to the existing docker-compose file):
version: "3"
services:
postgres:
image: postgres
healthcheck:
test: psql postgres --command "select 1" -U postgres
volumes:
- postgres-volume:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
pact-broker:
image: pactfoundation/pact-broker:2.107.0.1
ports:
- "9292:9292"
depends_on:
- postgres
environment:
PACT_BROKER_PORT: '9292'
PACT_BROKER_DATABASE_URL: "postgres://postgres:password@postgres/postgres"
PACT_BROKER_LOG_LEVEL: INFO
PACT_BROKER_SQL_LOG_LEVEL: DEBUG
PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES: "15"
volumes:
postgres-volume:
Now if spin up the above containers by running the command
docker-compose up -d
and navigate to http://localhost:9292/ we will see the following screen:
HTTP Contracts
We will first begin with synchronous request/response contracts.
Consumer's Side:
From the consumer's side, we would need first to add the following dependency:
testImplementation("au.com.dius.pact.consumer:junit5:4.5.5")
We will also need two gradle plugins:
- PACT plugin: to provide us with the PACT gradle tasks
- a plugin to provide us with GIT information about the project (will be needed for versioning the contracts)
plugins {
id("au.com.dius.pact") version "4.5.5"
id("org.ajoberstar.grgit") version "4.1.1"
}
pact {
publish {
pactBrokerUrl = "http://localhost:9292/" // our pact broker endpoint
version = grgit.head().abbreviatedId // the commit id will represent the contract version
consumerBranch = grgit.branch.current().getName() // we can tag the contracts with the branch name
}
}
Now that we have our dependencies set up, we can proceed with writing our contract tests. We will create a test class "SpellRestClientConsumerContractTest" in which we will add all our consumer REST contracts. For the sake of simplicity, we will only show one of the contracts here, however, you can check out the rest of them in the repository.
@ExtendWith(PactConsumerTestExt.class)
class SpellRestClientConsumerContractTest {
public static final String CONSUMER = "spell-client";
public static final String PROVIDER = "spell-server";
@Pact(consumer = CONSUMER, provider = PROVIDER)
@SuppressWarnings("unused")
V4Pact getAllSpellsContract(PactDslWithProvider builder) {
return builder
.given("spells exist")
.uponReceiving("a request to fetch all spells")
.path("/spells")
.method("GET")
.willRespondWith()
.status(200)
.body(PactDslJsonArray.arrayEachLike()
.stringType("name")
.stringType("description")
)
.toPact(V4Pact.class);
}
@Test
@DisplayName("validate contract for getting all spells")
@PactTestFor(pactMethod = "getAllSpellsContract")
void validate_contract_for_getting_all_spells(MockServer mockServer) {
RestTemplate restTemplate = new RestTemplateBuilder().rootUri(mockServer.getUrl()).build();
SpellClient spellRestClient = new SpellClient(restTemplate);
List<Spell> spells = spellRestClient.getSpells();
assertNotEquals(0, spells.size());
}
}
You can see that in this example, we are defining a contract for fetching all the existing magic spells under the path "/spells". We also assumed that some spells do exist. Then, the contract states that the endpoint should return a list of JSON objects, each containing two fields: "name" and "description". We then have a test to validate the contract and generate the needed file.
How PACT works here:
Adding the contract is not enough for the process. We also need to add a test with annotation @PactTestFor(pactMethod = "getAllSpellsContract")
in order to tell PACT that this test is to validate the mentioned contract and generate its file.
Running the test method would generate a file named "spell-client-spell-server.json" under the directory "build/pacts". This file is PACT's JSON representation of the defined contract.
Publishing to the Broker
Once we run our consumer contract tests and have the contract files autogenerated for us, we need to run the following command to publish them to the PACT broker:
gradlew client:pactPublish
Once the command ends, we can see that the contract is added to the broker:
We can see that the broker displays the contracts in a clear format that can act as versioned documentation.
Provider's Side:
Once the contracts are published to the broker, we can proceed from the Provider's side by downloading them and validating them against our application:
First, we would need first to add the following dependency:
testImplementation("au.com.dius.pact.provider:junit5spring:4.5.5")
And same the consumer, we need the PACT plugin alongside the GIT information provider one:
plugins {
id("au.com.dius.pact") version "4.5.5"
id("org.ajoberstar.grgit") version "4.1.1"
}
We also need to add some system properties for the validation procedure:
tasks.withType<Test>() {
systemProperty("pact.verifier.publishResults", "true") // for PACT to pulish the result of the contracts validation
systemProperty("pact.provider.branch", grgit.branch.current().getName()) // to tag the provider's validation with the current branch
systemProperty("pact.provider.version", grgit.head().abbreviatedId) // to tag the provider's validation with the current commit id as a version
}
Also, we need to specify the pact broker endpoint in the application.yml:
pactbroker:
url: http://localhost:9292
Now that we have everything set up, we can proceed with writing the provider's test class to validate the contracts. The trick with PACT is that as a provider, we don't need to do the actual validation. All we have to do is to prepare the grounds for PACT to run a simulation and validate the contracts. To do we will need to:
- Run the application to allow PACT to simulate the communication from the consumer
- Provide the needed state mocking to simulate all the scenarios
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = {
SpellController.class,
SpellService.class
}
)
@Provider("spell-server")
@PactBroker
@EnableAutoConfiguration
class SpellServerProviderContractTest {
@LocalServerPort
private int port;
@MockBean
private SpellFetcher spellFetcher;
@MockBean
private SpellPersister spellPersister;
@TestTemplate
@ExtendWith(PactVerificationSpringProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@BeforeEach
void setUp(PactVerificationContext context, Pact pact) {
if (pact.isRequestResponsePact()) {
context.setTarget(new HttpTestTarget("localhost", port));
}
}
@State("spells exist")
@SuppressWarnings("unused")
void toSpellsExistState() {
when(spellFetcher.getAll()).thenReturn(List.of(
Spell.builder().name("hokus pokus").description("does anything").build(),
Spell.builder().name("Lumos maxima").description("An improved version of the lumos spell").build()
));
}
}
You may have noticed that when we defined the contract from the Consumer's side, we added an assumption that "spells exist". To achieve this state, PACT provides us with what they call "state changers", where we have a method annotated with @State("spells exist")
and inside it, we can mock our internal services.
Once we run this test class, PACT will pull the contracts from the broker for the provider "spell-server", validate them, and publish the validation result back to the broker. When this is done, we can see that the result is reflected in the broker:
Messaging Contracts
Messaging contracts are a bit trickier since the server can become the consumer for the first command, and then the provider if it sends an event back, and the same applies to the client. For the sake of simplicity, we will assume that the client will send an event to the server that a new spell is requested, which the server will handle, but without sending anything back. In this scenario, the client becomes the provider since it sends the event, and the server becomes the consumer (it's confusing... I know)
Consumer's Side:
Same as before, we would need to add the required dependencies and configurations if not yet added. I will then jump to the actual code:
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "spell-client", providerType = ProviderType.ASYNCH, pactVersion = PactSpecVersion.V4)
class AddingNewSpellAsyncConsumerContractTest {
@Pact(consumer = "spell-server")
@SuppressWarnings("unused")
V4Pact newSpellRequestEventContract(MessagePactBuilder builder) {
return builder
.expectsToReceive("a new spell requested event")
.withMetadata(Map.of(
"routing-key", "spells.add",
"exchange", "spells"
))
.withContent(new PactDslJsonBody()
.stringType("name")
.stringType("description")
)
.toPact(V4Pact.class);
}
@Test
@DisplayName("validate contract for new spell requested event")
@PactTestFor(pactMethod = "newSpellRequestEventContract")
void validate_contract_for_new_spell_requested_event(V4Interaction.AsynchronousMessage message) throws IOException {
NewSpellRequestedEvent event = new ObjectMapper().readValue(message.getContents().getContents().getValue(), NewSpellRequestedEvent.class);
assertNotNull(event);
}
}
This code is at the server's side (the consumer in this case), where it expects an event to be received at the exchange "spells" and routing key "spells.add". The event body will contain two fields: "name" and "description". Ofcourse, we have a test method to validate this contract and generate the JSON file for it.
Once run, we can execute the following command to publish the new contract to the broker:
gradlew server:pactPublish
We can see that a new contract was added:
Unfortunately, PACT broker does not yet support clear visualization for messages contracts :/
Provider's side
As we did previously, we need to add a new test class in the provider (in this case the client) to validate the published contracts:
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.NONE,
classes = RabbitProperties.class
)
@Provider("spell-client") // for PACT to know to fetch the contracts for provider "spell-client"
@PactBroker
class SpellClientProviderContractTest {
@Autowired
private RabbitProperties rabbitProperties;
@TestTemplate
@ExtendWith(PactVerificationSpringProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@BeforeEach
void setUp(PactVerificationContext context, Pact pact) {
if (!pact.isRequestResponsePact()) {
context.setTarget(new MessageTestTarget());
}
}
@PactVerifyProvider("a new spell requested event")
@SuppressWarnings("unused")
MessageAndMetadata provideNewSpellRequestEvent() throws JsonProcessingException {
NewSpellRequestedEvent event = NewSpellRequestedEvent.builder().name(UUID.randomUUID().toString()).description(UUID.randomUUID().toString()).build();
byte[] eventBytes = new ObjectMapper().writeValueAsBytes(event);
Map<String, String> metadata = Map.of(
"routing-key", rabbitProperties.getAddSpellRoutingKey(),
"exchange", rabbitProperties.getAddSpellExchange()
);
return new MessageAndMetadata(eventBytes, metadata);
}
}
In the previous scenario, when we were validating REST contracts, we were running the controllers and expecting PACT to run a simulation of the communication. The case here is different. We only need to provide PACT with a sample of the event that would be sent in the real scenario, and PACT will do the validation from here onwards. This is being done by having a method annotated with @PactVerifyProvider("a new spell requested event")
where "a new spell requested event" is what is mentioned in the contract as the type of event expected to be sent.
Same as before, running the test class will pull the contracts from the broker, validate them, and publish the result back to the broker.
The full code can be found on this GitHub repository.