Kube Cloud Pt6 | Contract Testing
full course- Kube Cloud Pt6 | Contract Testing
- Kube Cloud Pt6 | Consumer Contract Tests for REST Endpoints
- Kube Cloud Pt6 | Provider Contract Test for REST Endpoints
- Kube Cloud Pt6 | Fulfill the Consumer Contract Test for REST Endpoint
- Kube Cloud Pt6 | Break the Contract from a Consumer Change
- 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:
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 Endpoints”Add yours →