Skip to content

Pact Integration

Siva. J edited this page Mar 16, 2023 · 1 revision

Consumer Driven Contracts with Pact

Modern applications need to be tested. There are different approaches and levels of testing that we can apply. Many applications are built from multiple services and traditionally projects employ integration tests to cover use cases that span multiple services. While this works, the approach often requires a lot of setups, is hard to maintain, and needs to be more brittle.

An alternative approach is Consumer Driven Contracts. These define dependencies between providers and consumers through contracts, which are created by customers and shared with providers, where they are automatically validated.

Consumers work against a provider mock when defining a contract, producers use that contract to verify their compliance. Consumer Driven Contracts don’t test application logic, they only enforce expectations on service interfaces.

As mentioned above, one framework to implement Consumer Driven Contracts is Pact. There are numerous language-specific implementations, we will be using PactJVM here.

PACT

It is a tool for testing HTTP requests, responses, and message integrations by using contract tests. The Pact name is a synonym of the word “contract”.

The pact provides a mechanism for creating a contract between a service consumer and a service provider and then providing the tools to validate that the consumer and provider adhere to the contract independently of each other.

Elements of pacts

  • The Consumer
  • The Provider
  • Pact broker

The Consumer

  • A consumer is a client that wants to receive some data from other services (for example, a web front end, or a message-receiving endpoint).
  • They define requirements for the endpoint such as HTTP headers, status code, payload, and response.
  • The contracts are generated during the unit test runtime.
  • After all, tests succeed it creates JSON files containing information on HTTP requests.

Pact_1

The Provider

  • A provider is a service or server that provides the data (for example, an API on a server that provides the data with the client needs, or the service that sends messages).
  • A tool for verifying contracts towards provider is called Provider Verifier. The verifier runs HTTP requests base on the contracts created by the consumer.
  • If the server response is in the form expecteContact tracing may sound like taking your contacts out of your eyes and then drawing circles around them. d by the consumer, the tests pass.

image

Pact Brocker

  • Pact Broker is a repository for contracts, allowing you to share pacts between consumers and providers.
  • It also contain version of pact contract files so the provider can verify itself against a fixed version of a contract.
  • It provide documentation for each pact as well as a visualization of the relationship between services.
  • Basically it a medium in which consumer upload the generated json file and then provider take this pact and verifed and return to this broker.

image

Project Setup

Created a simple Spring app using the Spring Web Starter dependency. The sample project implements a very basic UserController, backed by a UserRepository that delivers some static user data. There’s also a User record to model the data

Gradle Dependecies Setup

We need add following dependencies

plugins {
   id "java"
   id "org.springframework.boot" version "3.0.0"
   id "io.spring.dependency-management" version "1.1.0"
   id "au.com.dius.pact" version "4.4.2"
}

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-web'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   testImplementation 'au.com.dius.pact:consumer:4.4.2'
   testImplementation 'au.com.dius.pact.consumer:junit5:4.4.2'
   testImplementation 'au.com.dius.pact:provider:4.4.2'
   testImplementation 'au.com.dius.pact.provider:junit5:4.4.2'
   testImplementation 'au.com.dius.pact.provider:junit5spring:4.4.2'
   testImplementation 'org.junit-pioneer:junit-pioneer:2.0.0-RC1'
}

Consumer: Creating the contract

The easiest way to create the Pact file is via a unit test. The test goes in the same directory as all the other unit tests of the consumer.

The unit test will do two things: It verifies that our code can handle the expected provider responses and - as a nice side effect - it creates the Pact file.

First, we need to extend our test class with the Pact Consumer test extension.

@ExtendWith(PactConsumerTestExt.class)
public class PactConsumerJUnit5Test {
}

Then we need to define our pact in a method annotated with the @Pact annotation. Its parameters connect the pact to a provider and a consumer.

@Pact(provider = "UserServiceJUnit5", consumer = "UserConsumer")
public V4Pact getAllUsers(PactBuilder builder) {

   //noinspection ConstantConditions
   return builder
           .usingLegacyDsl()
           .given("A running user service")
           .uponReceiving("A request for a user list")
           .path("/users")
           .method("GET")
           .willRespondWith()
           .status(200)
           .headers(Map.of("Content-Type", "application/json"))
           .body(new PactDslJsonArray()
                   .object()
                   .integerMatching("id", "[0-9]*", 1)
                   .stringMatcher("name", "[a-zA-Z ]*", "Jane Doe")
                   .closeObject()
                   .object()
                   .integerMatching("id", "[0-9]*", 2)
                   .stringMatcher("name", "[a-zA-Z ]*", "John Doe")
                   .closeObject()
           )
           .toPact()
           .asV4Pact()
           .get();
}

Finally we need a test method that will execute the defined pact. We use the @PactTestFor annotation to reference the pact we want to use in the test – this is done by method name. JUnit5 injects the mock server as a parameter. The test goes on to create a RestTemplate, wire it up to the mock server and execute a request. The pact is serialized to the build folder as a json file

The complete class now looks like this:

package com.pact.consumer.producer;

import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactBuilder;
import au.com.dius.pact.consumer.dsl.PactDslJsonArray;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.client.RestTemplate;

import java.util.Arrays;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(PactConsumerTestExt.class)
public class PactConsumerJUnit5Test {

   @Pact(provider = "UserServiceJUnit5", consumer = "UserConsumer")
   public V4Pact getAllUsers(PactBuilder builder) {

       //noinspection ConstantConditions
       return builder
               .usingLegacyDsl()
               .given("A running user service")
               .uponReceiving("A request for a user list")
               .path("/users")
               .method("GET")
               .willRespondWith()
               .status(200)
               .headers(Map.of("Content-Type", "application/json"))
               .body(new PactDslJsonArray()
                       .object()
                       .integerMatching("id", "[0-9]*", 1)
                       .stringMatcher("name", "[a-zA-Z ]*", "Jane Doe")
                       .closeObject()
                       .object()
                       .integerMatching("id", "[0-9]*", 2)
                       .stringMatcher("name", "[a-zA-Z ]*", "John Doe")
                       .closeObject()
               )
               .toPact()
               .asV4Pact()
               .get();
   }

   @Pact(provider = "UserServiceJUnit5", consumer = "UserConsumer")
   public V4Pact getUserById(PactBuilder builder) {

       return builder
               .usingLegacyDsl()
               .given("A running user service")
               .uponReceiving("A request for a user")
               .path("/users/1")
               .method("GET")
               .willRespondWith()
               .status(200)
               .headers(Map.of("Content-Type", "application/json"))
               .body(new PactDslJsonBody()
                       .integerMatching("id", "[0-9]*", 1)
                       .stringMatcher("name", "[a-zA-Z ]*", "Jane Doe")
               )
               .toPact()
               .asV4Pact()
               .get();
   }

   @Test
   @PactTestFor(providerName = "UserServiceJUnit5", pactMethod = "getUserById")
   void getUserById(MockServer mockServer) {
       RestTemplate restTemplate = new RestTemplateBuilder().rootUri(mockServer.getUrl()).build();
       User user = restTemplate.getForObject("/users/1", User.class);

       assertThat(user).isNotNull();
       assertThat(user.id()).isNotNegative();
       assertThat(user.name()).isNotEmpty();
   }

   @Test
   @PactTestFor(providerName = "UserServiceJUnit5", pactMethod = "getAllUsers")
   void getAllUsers(MockServer mockServer) {
       RestTemplate restTemplate = new RestTemplateBuilder().rootUri(mockServer.getUrl()).build();
       List<User> users = Arrays.asList(restTemplate.getForObject("/users", User[].class));

       assertThat(users)
               .isNotNull()
               .hasSize(2);

       users.forEach(user -> {
           assertThat(user.id()).isNotNegative();
           assertThat(user.name()).isNotEmpty();
       });
   }

}

If we run the consumer test will create a contract for all the configured interactions between a consumer and a provider. This contract is serialized as a json file into the build directory of the project. Here, it is build/pacts and if you run it you will find a file called** UserConsumer-UserServiceJUnit5.json**. It looks like this:

{
 "consumer": {
   "name": "UserConsumer"
 },
 "interactions": [
   {
     "comments": {
       "text": [

       ]
     },
     "description": "A request for a user",
     "key": "5fa8fa6b",
     "pending": false,
     "providerStates": [
       {
         "name": "A running user service"
       }
     ],
     "request": {
       "method": "GET",
       "path": "/users/1"
     },
     "response": {
       "body": {
         "content": {
           "id": 1,
           "name": "Jane Doe"
         },
         "contentType": "application/json",
         "encoded": false
       },
       "headers": {
         "Content-Type": [
           "application/json"
         ]
       },
       "matchingRules": {
         "body": {
           "$.id": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "integer"
               },
               {
                 "match": "regex",
                 "regex": "[0-9]*"
               }
             ]
           },
           "$.name": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "regex",
                 "regex": "[a-zA-Z ]*"
               }
             ]
           }
         }
       },
       "status": 200
     },
     "transport": "https",
     "type": "Synchronous/HTTP"
   },
   {
     "comments": {
       "text": [

       ]
     },
     "description": "A request for a user list",
     "key": "2149737f",
     "pending": false,
     "providerStates": [
       {
         "name": "A running user service"
       }
     ],
     "request": {
       "method": "GET",
       "path": "/users"
     },
     "response": {
       "body": {
         "content": [
           {
             "id": 1,
             "name": "Jane Doe"
           },
           {
             "id": 2,
             "name": "John Doe"
           }
         ],
         "contentType": "application/json",
         "encoded": false
       },
       "headers": {
         "Content-Type": [
           "application/json"
         ]
       },
       "matchingRules": {
         "body": {
           "$[0].id": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "integer"
               },
               {
                 "match": "regex",
                 "regex": "[0-9]*"
               }
             ]
           },
           "$[0].name": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "regex",
                 "regex": "[a-zA-Z ]*"
               }
             ]
           },
           "$[1].id": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "integer"
               },
               {
                 "match": "regex",
                 "regex": "[0-9]*"
               }
             ]
           },
           "$[1].name": {
             "combine": "AND",
             "matchers": [
               {
                 "match": "regex",
                 "regex": "[a-zA-Z ]*"
               }
             ]
           }
         }
       },
       "status": 200
     },
     "transport": "https",
     "type": "Synchronous/HTTP"
   }
 ],
 "metadata": {
   "pact-jvm": {
     "version": "4.4.2"
   },
   "pactSpecification": {
     "version": "4.0"
   }
 },
 "provider": {
   "name": "UserServiceJUnit5"
 }
}

Provider Setup

Firstly, we use the @SpringBootTest annotation to bootstrap our service, assigning a random port. The port is then injected into the port variable using the @LocalServerPort annotation. We use it in a setup method that configures the Pact verification context to connect to our service.

We also need the @Provider annotation and specify the base folder where our contracts reside using the @PactFolder annotation.

As the test framework creates test runs for all the interactions in the serialized contract, we do not defined specific tests, instead we define a @TestTemplate that is extended with a PactVerificationInvocationContextProvider.

@WebMvcTest
@Provider("UserServiceJUnit5")
@PactFolder("build/pacts")
public class PactProviderSpringJUnit5Test {

   @TestConfiguration
   static class Config {

       @Bean
       public UserRepository userRepo() {
           return new UserRepository();
       }
   }

   @Autowired
   MockMvc mockMvc;
   @Autowired
   UserRepository userRepository;

   @BeforeEach
   public void setup(PactVerificationContext context) {
       context.setTarget(new MockMvcTestTarget(mockMvc));
   }

   // see: https://docs.pact.io/getting_started/provider_states
   @State("A running user service")
   void setupUserService() {
       // no state setup ATM
   }

   @TestTemplate
   @ExtendWith(PactVerificationInvocationContextProvider.class)
   void pactVerificationTestTemplate(PactVerificationContext context) {
       context.verifyInteraction();
   }
}

Verifying contracts

Now we can verify the tests by using below command

gradle pactVerify

We will see the below output

image

Working with the contract

In this section we will go through several scenarios that can happen in the lifecycle of an API and show how Pact will behave.

Provider adds a field to the API

The provider adds a new field to the API. Nothing will happen, everything will still pass because the consumer only cares about the attributes in its contract.

Provider deletes a field from the API

Removal of an unused field: The provider decides to remove the legacyId field from the API. They can just do it because the consumer does not expect it to be part of the response. The contract test still passes.

Removal of a used field: The provider decides to rename the name field to fullname (which is the same as removing the name field from the contract’s perspective):

image

The provider test fails because the consumer’s contract is violated since it expects the name field.

Sharing contracts with the Pact Broker

The pact broker requires a postgres database to work, so it’s convenient to use docker compose to spin up and configure both containers. Following is the docker compose file, it’s a slightly adapted version of the one provided by pactfoundation here.

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:latest
    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: "5"
      PACT_BROKER_BASE_URL: 'https://localhost http://localhost http://localhost:9292 http://pact-broker:9292 https://host.docker.internal http://host.docker.internal http://host.docker.internal:9292'

volumes:
  postgres-volume:

Versioning

Consumers and providers can also have different versions, though. Every time we publish a contract, we need to tell the broker which consumer version published a contract. When a provider verifies a contract, it’s also important to know which version of a provider verified or didn’t verify a contract. Have a look at the documentation for an elaborate explanation.

Publishing contracts with gradle plugin

We need to add the gradle plugin to your build file.

id "au.com.dius.pact" version "4.4.2"

Afterwards, we need to specify the broker URL in the plugin’s configuration. the configuration looks like this:

pact {
    broker {
        pactBrokerUrl = "http://localhost:9292"
    }
}

Now we need to run the following Gradle task to publish your contracts:

gradle pactPublish

Go to a browser and visit http://localhost:9292/ to open the broker web UI.

image

Retrieving and verifying contracts

Now that we have successfully published our contracts to the broker, let’s integrateretrieve and verify contracts

Plain JUnit5 provider tests

Previously, we have seen a JUnit5 provider test reading from the local filesystem. We’ll build on it and connect it to the broker.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("UserServiceJUnit5")
@PactBroker(url = "http://localhost:9292")
@SetSystemProperty.SetSystemProperties({
       @SetSystemProperty(key = "pact.verifier.publishResults", value = "true"),
})
public class PactProviderJUnit5WithBrokerTest {

   @LocalServerPort
   int port;

   @BeforeEach
   public void setup(PactVerificationContext context) {
       context.setTarget(new HttpTestTarget("localhost", port));
   }

   // see: https://docs.pact.io/getting_started/provider_states
   @State("A running user service")
   void setupUserService() {
       // no state setup ATM
   }

   @TestTemplate
   @ExtendWith(PactVerificationInvocationContextProvider.class)
   void pactVerificationTestTemplate(PactVerificationContext context) {
       context.verifyInteraction();
   }
}

Here we are using the @PactBroker annotation to specify the url of the broker. This is enough for pact to successfully retrieve contracts for the provider specified by the @Provider annotation.

Now we need to add provider configuration to the build file. The official documentation for that is here

pact {

    publish {
           }

    broker {
        pactBrokerUrl = "http://localhost:9292"
    }

    serviceProviders {

        UserServiceJUnit5 {
                  }
    }
}

Pact executes the contracts against the provider service. It defaults to http://localhost:8080

we’ll manually start our application before invoking Gradle by using below command

gradle pactVerify
Clone this wiki locally