Kube Cloud Pt6 | Consumer Contract Tests for REST Endpoints

Kube Cloud Pt6 | Consumer Contract Tests for REST Endpoints

Kube Cloud Pt6 | Contract Testing

full course
  1. Kube Cloud Pt6 | Contract Testing
  2. Kube Cloud Pt6 | Consumer Contract Tests for REST Endpoints
  3. Kube Cloud Pt6 | Provider Contract Test for REST Endpoints
  4. Kube Cloud Pt6 | Fulfill the Consumer Contract Test for REST Endpoint
  5. Kube Cloud Pt6 | Break the Contract from a Consumer Change
  6. Kube Cloud Pt6 | Synchronous Contract Testing Conclusion

Let’s start with cloud-application, create a new branch

git checkout -b client-contract

Lets add the necessary dependencies to our pom.xml

         <pact.version>4.0.10</pact.version>
        <pact.provider.maven.plugin>4.3.5</pact.provider.maven.plugin>
    </properties>
...
        <!-- Contract Testing -->
        <dependency>
            <groupId>au.com.dius</groupId>
            <artifactId>pact-jvm-consumer-junit5</artifactId>
            <version>${pact.version}</version>
            <scope>test</scope>
        </dependency>
...
            <plugin>
                <groupId>au.com.dius.pact.provider</groupId>
                <artifactId>maven</artifactId>
                <version${pact.provider.maven.plugin}</version>
                <configuration>
                    <pactBrokerUrl>${env.PACTFLOW_URL}</pactBrokerUrl>
                    <pactBrokerToken>${env.PACTFLOW_TOKEN}</pactBrokerToken>
                    <projectVersion>${env.PACT_PUBLISH_CONSUMER_VERSION}</projectVersion>
                    <pactBrokerAuthenticationScheme>Bearer</pactBrokerAuthenticationScheme>
                </configuration>
            </plugin>

Pact is mostly a testing framework so these dependencies add what’s needed for testing. The plugin allows us to access pact functions (publish, verify, etc…) from maven, which is useful in our pipeline so that we don’t have to install any additional CLI tools to our github actions workflow.

Create a test class in src/test/java/com/bullyrooks/cloud_application/message_generator.client called MessageGeneratorClientTest. Add this content

package com.bullyrooks.cloud_application.message_generator.client;

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.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import com.bullyrooks.cloud_application.message_generator.client.dto.MessageResponseDTO;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import feign.Contract;
import feign.Feign;
import feign.codec.Decoder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Tags;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.openfeign.support.*;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageDecoder;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.client.RestTemplate;

import javax.ws.rs.HttpMethod;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith(PactConsumerTestExt.class)
@ExtendWith(SpringExtension.class)
@PactTestFor(providerName = MessageGeneratorClientTest.PROVIDER, port = MessageGeneratorClientTest.PROVIDER_PORT)
@Slf4j
@Tag("ContractTest")
public class MessageGeneratorClientTest {
    final static String PROVIDER = "message-generator";
    final static String PROVIDER_PORT = "8888";
    final static String CONSUMER = "cloud-application";
    final static String JSON_RESPONSE = "message-generator-response.json";

    private MessageGeneratorClient messageGeneratorClient;

    @Autowired
    ApplicationContext context;
    @BeforeEach
    public void init() {
        messageGeneratorClient = Feign.builder()
                .contract(new SpringMvcContract())
                .decoder(feignDecoder())
                .target(MessageGeneratorClient.class, "http://localhost:"+PROVIDER_PORT);
        log.info("messageGeneratorClient: {}", messageGeneratorClient);
    }
    public Decoder feignDecoder() {
        List<HttpMessageConverter<?>> converters = new RestTemplate().getMessageConverters();
        ObjectFactory<HttpMessageConverters> factory = () -> new HttpMessageConverters(converters);
        return new ResponseEntityDecoder(new SpringDecoder(factory, context.getBeanProvider(HttpMessageConverterCustomizer.class)));
    }
    @Pact(provider = MessageGeneratorClientTest.PROVIDER, consumer = MessageGeneratorClientTest.CONSUMER)
    public RequestResponsePact generateMessagePact(PactDslWithProvider builder) throws JSONException, IOException {
        // @formatter:off
        return builder
                .given("generator creates a message")
                .uponReceiving("a request to GET a message")
                .path("/message")
                .method(HttpMethod.GET)
                .willRespondWith()
                .status(HttpStatus.OK.value())
                .matchHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                .body(getJsonBody())
                .toPact();
        // @formatter:on
    }

    private JSONObject getJsonBody() throws IOException, JSONException {
        File f = new File("src/test/resources/json/" + JSON_RESPONSE);
        InputStream is = new FileInputStream(f);
        String jsonTxt = IOUtils.toString(is, "UTF-8");
        log.info("test data: {}" + jsonTxt);
        JSONObject json = new JSONObject(jsonTxt);
        return json;
    }

    @Test
    @PactTestFor(pactMethod="generateMessagePact")
    public void generateMessage() {
        MessageResponseDTO response = messageGeneratorClient.getMessage();
        assertTrue(StringUtils.isNotBlank(response.getMessage()));
    }
}

we’ll also need this test data class in src/test/resources/json/message-generator-response.json

{
  "message": "All dwarfs are bastards in their father's eyes"
}

I got this message directly from a postman request to the message-generator /message endpoint. So yours can be different if you prefer.

Here’s what’s going on in the test:t

  • The annotations are setting up the test, the important part is the @PactTestFor which is going to tell the test how to setup a mock server that our client will talk to.
  • In init() we’re going to inject a ‘default’ feign configuration into our client. Feign is declarative, which means that it depends on another library for the implementation. Previously, we’ve been just relying on the kubernetes implementation (which is why you can’t make ‘partial message’ requests locally, there’s no kubernetes for discovery). However, we need feign to talk to our mock server, so we’re just configuring feign to point to it here.
  • generateMessagePact is going to define our actual contact. This essentially tells the mock server what endpoints it should create and listen to and how to respond when they receive requests. We’re injecting the content of our json file here.
  • The actual test is very basic. We just use our client to get a response and validate the structure of the data returned. The @PactTestFor pactMethod must match the name of the contract method above for this to work.
  • You can apply whatever assertions you want. This is for you. Since you’ve already defined the data structure you expect this is mostly informational to the future developers about the types of validation you expect. For example, if you change your test data file, it might break your test and you might want to know that.

Run the test. It should pass. Lets take a look at the target/pacts directory now. You should see a new file there: cloud-application-message-generator.json. This is the contract file that pact uses and should be mostly human readable.

Pact Credentials

Log into pact and hit the gear icon to get to the settings

On the API Tokens page get the url and the read/write token and save them for later

We’re going to add another secret to the build so, log into the github repo for your cloud-application code and go to the settings/secrets section. Add a new secret for PACTFLOW_TOKEN and put your read/write token there

Update Workflows

We’re going to update both features and main workflows in our .github/workflows directory

features.yaml

name: Cloud Application Feature Branch Build

on:
  push:
    branches-ignore:
      - main
env:
  PACTFLOW_URL: https://bullyrooks.pactflow.io
  PACTFLOW_TOKEN: ${{secrets.PACTFLOW_TOKEN}}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Shallow clones should be disabled for a better relevancy of analysis
      - name: Set environment variables
        run: |
          # Short name for current branch. For PRs, use target branch (base ref)
          GIT_BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
          echo "GIT_BRANCH=$GIT_BRANCH" >> $GITHUB_ENV
          echo "VERSION=$GITHUB_SHA"
          echo "VERSION=$GITHUB_SHA" >> $GITHUB_ENV

      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'adopt'
          cache: maven

      - name: Maven Tests
        run: ./mvnw -B test

      - name: Publish Contracts
        run: |
         ./mvnw pact:publish \
         -Dpact.publish.consumer.branchName=${{env.GIT_BRANCH}} \
         -Dconsumer.version=${{env.VERSION}} \
         -Dpact.consumer.tags=development

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Cache Docker layers
        uses: actions/cache@v2
        with:
          path: /tmp/.buildx-cache
          # Key is named differently to avoid collision
          key: ${{ runner.os }}-multi-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-multi-buildx

      - name: Build and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          context: .
          push: false
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
        # This ugly bit is necessary if you don't want your cache to grow forever
        # till it hits GitHub's limit of 5GB.
        # Temp fix
        # https://github.com/docker/build-push-action/issues/252
        # https://github.com/moby/buildkit/issues/1896
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache
      - name: Wait for contract verification
        run: |
          sleep 40s

      - name: Pact Can-i-deploy
        run: |
          docker run --rm pactfoundation/pact-cli:latest \
          broker can-i-deploy \
          --pacticipant=cloud-application  \
          --to-environment=okteto \
          --version=${{ env.VERSION }}  \
          --broker-base-url=${{ env.PACTFLOW_URL }} \
          --broker-token=${{ env.PACTFLOW_TOKEN }}

We’re setting some global environment variables to connect and authenticate at pactflow. Then we’re pulling the git branch into the version and branch name of the contract that gets published. Then we’re leveraging the pact plugin via maven to publish the contract the test generates to pactflow. I’ve cleaned up some of the docker build here (so we’re not pushing to canister.io) and added the can-i-deploy check which will show a build failure in our PR to show that this consumer cannot be deployed into production.

We’re going to do something similar to main.yaml

name: Cloud Application Main Branch Build

on:
  push:
    branches:
      - main

env:
  PACTFLOW_URL: https://bullyrooks.pactflow.io
  PACTFLOW_TOKEN: ${{secrets.PACTFLOW_TOKEN}}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Shallow clones should be disabled for a better relevancy of analysis

      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'adopt'
          cache: maven
      - name: find current version
        uses: actions-ecosystem/action-get-latest-tag@v1
        id: get-latest-tag

      - name: get current version tag
        run: |
          echo "VERSION=${{ steps.get-latest-tag.outputs.tag }}" >> $GITHUB_ENV
          echo ${{ env.VERSION }}

      - name: Echo current version tag
        run: |
          echo $VERSION
          echo ${{ env.VERSION }}

      - name: Bump version
        run: |
          git config --global user.email "[email protected]"
          git config --global user.name "Actions"
          git fetch --tags
          wget -O - https://raw.githubusercontent.com/treeder/bump/master/gitbump.sh | bash
          echo "VERSION=$(git tag --sort=-v:refname --list "v[0-9]*" | head -n 1 | cut -c 2-)" >> $GITHUB_ENV
          echo ${{ env.VERSION }}

      - name: Build with Maven
        run: ./mvnw -B test

      - name: Publish Pact Contract
        run: |
          ./mvnw pact:publish \
          -Dpact.publish.consumer.branchName=main \
          -Dconsumer.version=${{env.VERSION}} \
          -Dpact.consumer.tags=pre-okteto

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Login to canister.io
        uses: docker/login-action@v1
        with:
          registry: cloud.canister.io:5000
          username: ${{ secrets.CANISTER_USERNAME }}
          password: ${{ secrets.CANISTER_PASSWORD }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v3
        with:
          images: cloud.canister.io:5000/bullyrooks/cloud-application
          #setting value manually, but could come from git tag
          tags: |
            type=ref,event=tag

      - name: Cache Docker layers
        uses: actions/cache@v2
        with:
          path: /tmp/.buildx-cache
          # Key is named differently to avoid collision
          key: ${{ runner.os }}-multi-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-multi-buildx

      - name: Build and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: |
            cloud.canister.io:5000/bullyrooks/cloud-application:${{ env.VERSION }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
        # This ugly bit is necessary if you don't want your cache to grow forever
        # till it hits GitHub's limit of 5GB.
        # Temp fix
        # https://github.com/docker/build-push-action/issues/252
        # https://github.com/moby/buildkit/issues/1896
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

      - name: Publish Helm chart
        uses: stefanprodan/helm-gh-pages@master
        with:
          token: ${{ secrets.CHART_TOKEN }}
          charts_dir: helm
          charts_url: https://bullyrooks.github.io/helm-charts/
          repository: helm-charts
          branch: gh-charts
          app_version: ${{ env.VERSION }}
          chart_version: ${{ env.VERSION }}

      - name: Pact Can-i-deploy
        run: |
          docker run --rm pactfoundation/pact-cli:latest \
          broker can-i-deploy \
          --pacticipant=cloud-application  \
          --to-environment=okteto \
          --version=${{ env.VERSION }}  \
          --broker-base-url=${{ env.PACTFLOW_URL }} \
          --broker-token=${{ env.PACTFLOW_TOKEN }}


      - name: Deploy
        uses: WyriHaximus/github-action-helm3@v2
        with:
          exec: |
            helm repo add bullyrooks https://bullyrooks.github.io/helm-charts/
            helm repo update
            echo "helm upgrade cloud-application bullyrooks/cloud-application --install --version ${{ env.VERSION }}"
            sleep 60s
            helm repo update
            helm upgrade cloud-application bullyrooks/cloud-application --install --version ${{ env.VERSION }}
          kubeconfig: '${{ secrets.KUBECONFIG }}'

      - name: Create Pact Release Record
        run: |
          docker run --rm pactfoundation/pact-cli:latest \
          broker record-release \
          --environment=okteto \
          --pacticipant=cloud-application \
          --version=${{ env.VERSION }} \
          --broker-base-url=${{ env.PACTFLOW_URL }} \
          --broker-token=${{ env.PACTFLOW_TOKEN }}

      - name: Create Pact Deployment Record
        run: |
          docker run --rm pactfoundation/pact-cli:latest \
          broker record-deployment \
          --environment=okteto \
          --pacticipant=cloud-application \
          --version=${{ env.VERSION }} \
          --broker-base-url=${{ env.PACTFLOW_URL }} \
          --broker-token=${{ env.PACTFLOW_TOKEN }}

Pact relies HEAVILY on a good versioning strategy. This is mostly the same except the can-i-deploy refers to an environment and you can see that there’s some additional functionality around release and deployment after the helm deploy. We’re going to rely on these to tell pact which versions of the consumer and provider meet their contracts before they get deployed to the correct environment (okteto)

Verify

That should be it. Go ahead and push to your feature branch. Lets go ahead and create a PR while we’re at it. Navigate to your github repo pull requests page and you should see something like this:

Click Compare & pull request and you should see this

Click create pull request. You should see a failure

Click Details and you should see this:

(your error message will probably say something different)

Lets go into pactflow and confirm that we’re seeing our new contract

And if you click on view pact, you should see a visual representation of the contract file that we published

Now lets go build the provider verification of the contract so we can get the contract to pass and deploy our client application.

0 comments on “Kube Cloud Pt6 | Consumer Contract Tests for REST EndpointsAdd yours →

Leave a Reply

Your email address will not be published. Required fields are marked *