Contract Testing in Kubernetes with Testkube

Contract testing in Kubernetes with Testkube: REST and event contracts checked by one gate that returns pass or fail
10 mins
read
Lakshmi Mukkur
Solutions Architect
Testkube
Read more from
Lakshmi Mukkur

Table of Contents

Start your free trial.

Start your free trial.

Start your free trial.

Explore Testkube hands-on.
30 days
no commitment
$0
no credit card needed

Subscribe to our monthly newsletter to stay up to date with all-things Testkube.

Please disable pixel blocker extension
You have successfully subscribed to the Testkube newsletter.
You have successfully subscribed to the Testkube newsletter.
Oops! Something went wrong while submitting the form.
10 mins
read
Lakshmi Mukkur
Solutions Architect
Testkube
Read more from
Lakshmi Mukkur
Lakshmi Mukkur
Solutions Architect
Testkube
Test a Kubernetes service against its neighbors' contracts: Prism mocks for REST, Microcks for Kafka events, and one Testkube gate that gives CI a single verdict.

Table of Contents

Executive Summary

This is part 2 of our contract testing series. Part 1 covered what a contract is, the three main approaches and when contract testing pays off. This post puts the schema-driven approach to work inside Kubernetes. Each change runs against mocks built from its neighbors' contracts: Prism for REST calls, Microcks for Kafka events, and one Testkube quality gate that gives the pipeline a single verdict.

From Contracts to Checks

In the schema-driven approach, the provider keeps its contract as an API description, and checks confirm that the real service matches it. Here the descriptions are OpenAPI files for the REST APIs and an AsyncAPI file for the event, kept in Git next to the code.

The same files also generate the mocks, a practice often called service virtualization. A mock built from a contract does more than answer: it can reject a request that breaks the contract, or check an event against its schema, so the break shows up on the branch that caused it. Because the mock comes from the file, it changes when the contract does, so it does not go stale the way a hand-written mock can.

The hard part has been where to run it. The mocks, a message broker and the service under test must start together, find each other and go away after the run, on every change. A Testkube Test Workflow does exactly that inside your own Kubernetes cluster.

The Example: One Service, Two APIs and One Event

The sample app is an order pipeline on Kubernetes, in order-demo-enterprise. This post tests order-service, which depends on three neighbors:

Neighbor How order-service depends on it Contract file
auth-service Calls POST /authorize with the order id and a token before it accepts an order contracts/auth-service.openapi.yaml
product-catalog Calls GET /products/:id when the order carries a sku contracts/product-catalog.openapi.yaml
inventory-service Reads the order-placed event from Kafka and uses only its id field contracts/order-placed.asyncapi.yaml

In part 1's terms, order-service is a consumer of auth-service and product-catalog, and the provider of the order-placed event that inventory-service consumes. So the checks cover both directions: the requests order-service sends, and the event it publishes.

The contract files were written from the app's existing Pact files, so every field comes from a real interaction. Each file carries named examples, because Microcks builds its mock responses from them.

Two tools share the work. Prism turns an OpenAPI file into a mock server that validates every request it receives. Microcks, a CNCF incubating project, mocks REST APIs from OpenAPI and also tests events on Kafka against an AsyncAPI file.

One Run at a Glance

Diagram of one contract testing gate run: CI starts mock-services-gate, which runs the Prism and Microcks Test Workflows in parallel and returns one verdict, with an AI agent on failure

The CI pipeline starts the gate for a branch, and the gate runs both workflows at once. Each workflow starts its own Kafka broker and runs order-service from that branch against its mocks. The branch passes only when both workflows pass, and a failed gate starts the AI agent.

Prism Mocks as Test Workflow Services

In Testkube, each mock is a service of the Test Workflow. A service runs in its own pod, is ready before the test step starts, and is gone when the run ends. The Prism workflow starts three of them: a Kafka broker and one Prism mock per REST dependency. Its test step starts order-service from the branch, places an order with a sku, and expects a 201 and the order-placed event.

The run in the dashboard lists the three services under Start services, before the test step:

Testkube mock-services-prism run: the Start services step lists authmock, catalogmock and kafka before the order-service test step

The workflow, trimmed (the full file is in the repo):

kind: TestWorkflow
apiVersion: testworkflows.testkube.io/v1
metadata:
  name: mock-services-prism
spec:
  config:
    branch:
      type: string
      default: mock-services-demo
  content:
    git:
      uri: https://github.com/neuralnimbus22/order-demo-enterprise
      revision: '{{config.branch}}'
  container:
    workingDir: /data/repo
  services:
    kafka:
      image: apache/kafka:3.7.0
      # single-node KRaft settings trimmed
      shell: KAFKA_ADVERTISED_LISTENERS="PLAINTEXT://$(hostname -i):9092" exec /etc/kafka/docker/run
      readinessProbe:
        tcpSocket:
          port: 9092
    authmock:
      image: stoplight/prism:5.15.10
      content:
        git:
          uri: https://github.com/neuralnimbus22/order-demo-enterprise
          revision: '{{config.branch}}'
          paths:
          - contracts
      args: [mock, -m, "false", -h, 0.0.0.0, -p, "4010", /data/repo/contracts/auth-service.openapi.yaml]
      logs: always
      readinessProbe:
        tcpSocket:
          port: 4010
    # catalogmock: the same, started with contracts/product-catalog.openapi.yaml
  steps:
  - name: Run order-service against Prism mocks and per-run Kafka
    container:
      image: node:20
    run:
      env:
      - name: AUTH_URL
        value: http://{{services.authmock.0.ip}}:4010
      - name: CATALOG_URL
        value: http://{{services.catalogmock.0.ip}}:4010
      - name: KAFKA_BROKERS
        value: '{{services.kafka.0.ip}}:9092'
      shell: |
        node services/order/server.js &
        node tests/mock-services/prism-check.js
    artifacts:
      paths:
      - target/mock-services/*.xml

What each part does:

  • content.git checks out the branch under test, so order-service and its contracts come from the same commit (content from Git).
  • Each mock is the stock Prism image started with one contract file. -h 0.0.0.0 makes it listen beyond localhost. With version 5.15.10, Prism also needed -m false (one process) to start in a container.
  • The test step reaches each service at {{services.<name>.0.ip}}. The service names have no dashes, because a dash inside that expression reads as a minus sign.
  • Clients reach Kafka by pod IP, so the broker advertises its own IP when it starts.
  • logs: always keeps each mock's log with the run. That log is where Prism writes the request that broke its contract.
  • The check writes a JUnit report, which Testkube keeps as an artifact of the run.

Microcks for the Kafka Event

Prism speaks only HTTP, so it never sees the event that order-service publishes. The Microcks workflow covers it in one test step: it imports the three contracts into Microcks, places an order through order-service while Microcks serves the REST mocks, and then runs a Microcks contract test on the order-placed topic.

That event test is a Microcks test of type ASYNC_API_SCHEMA, which consumes the messages on a topic and checks each one against the AsyncAPI schema (test parameters). Its endpoint points at the broker of the current run, and the code asks Microcks to read the topic from its first offset:

const endpoint = `kafka://${KAFKA_BROKERS[0]}/${KAFKA_TOPIC}?startOffset=0`;
const spec = JSON.stringify({ serviceId: 'order-placed:1.0.0', testEndpoint: endpoint,
                              runnerType: 'ASYNC_API_SCHEMA', timeout: 10000 });
const res = await fetch(`${MICROCKS_URL}/api/tests`, { method: 'POST',
  headers: { 'Content-Type': 'application/json' }, body: spec });

Kafka is still a service of each run. Microcks itself is installed once in the cluster, in its own namespace, with its async minion beside it. Each of the two needs the other's address when it starts, while a Test Workflow service gets its IP only after it starts, so a shared install with stable DNS names breaks that loop. The minion takes the broker from the test endpoint, so one Microcks serves every run, and the workflow receives its address as a configuration value.

One Gate, One Verdict

Neither workflow catches every break, so a third workflow runs both for the same branch. It uses execute to start the two workflows in parallel and fails when either one fails (running workflows from a workflow):

kind: TestWorkflow
apiVersion: testworkflows.testkube.io/v1
metadata:
  name: mock-services-gate
  labels:
    mocks: gate
spec:
  config:
    branch:
      type: string
      default: mock-services-demo
  steps:
  - name: Run the Prism and Microcks workflows for the branch
    execute:
      parallelism: 2
      workflows:
      - name: mock-services-prism
        config:
          branch: '{{config.branch}}'
      - name: mock-services-microcks
        config:
          branch: '{{config.branch}}'

In the dashboard, the gate run lists its two child runs, and it fails when one of them fails:

Testkube mock-services-gate-8 run started from Jenkins, with child runs mock-services-microcks-15 passed and mock-services-prism-15 failed

The pipeline keeps what it does today and adds one step. Jenkins runs the Testkube CLI in a pod, sets its context with an API token stored as a Jenkins credential, and starts the gate for the branch (Jenkins):

sh 'testkube set context --api-key "$TK_API_TOKEN" --org-id "$TK_ORG" --env-id "$TK_ENV"'
sh 'testkube run testworkflow mock-services-gate --config branch="$BRANCH" -f'

The end of the Jenkins console for the gate run above:

Jenkins console output: the Testkube CLI lists the two child runs, then the build ends with Finished: FAILURE

With -f, the CLI follows the run and exits with code 1 when the run fails, so the Jenkins build fails with it. One tip if your Jenkins agents run on Kubernetes: the kubeshop/testkube-cli image runs as user 1001, while the agent creates the workspace as user 1000. Run the CLI container as user 1000 and point HOME at the agent's work directory, or the shell steps never start.

Jenkins is one way in. The same gate can start from Bitbucket Pipelines or any other CI, on a schedule, from Kubernetes or Git events, through the CLI or the REST API, or from another workflow (ways to trigger a workflow). Each workflow's CI/CD Integration tab in the dashboard shows the snippet for each CI tool.

What Each Tool Caught

Each tool caught the change it validates, and the gate caught both. The gate ran on three branches of order-service; each break branch changes one line.

Branch What changes Prism Microcks Gate
mock-services-demo Nothing Passed Passed Passed
mock-services-demo-break The order-placed event sends orderId instead of id Passed Failed Failed
mock-services-demo-rest-break The POST /authorize request sends order_id instead of orderId Failed Passed Failed

Each gate run took about 30 seconds, with both checks running in parallel.

On the event change, the Microcks test named the contract and the missing field:

order-placed contract (order-placed:1.0.0) violated
SUBSCRIBE order-placed: required property 'id' not found

In the dashboard, the step log of the Microcks run shows it after the two checks that passed:

Step log of a Testkube mock-services-microcks run: the import and the order pass, then the order-placed event fails the AsyncAPI contract

On the request change, order-service answered with a generic 502, by design: it never names its upstream services. The evidence sits in the authmock log of the same run, where Prism rejected the request:

[VALIDATOR] ✖ error Request body must have required property 'orderId'
Request terminated with error: ...UNPROCESSABLE_ENTITY: Invalid request

In the dashboard, it is in the authmock service log of the failed Prism run:

Prism authmock service log in a failed Testkube run, reporting that the request body has no orderId

Neither tool caught both changes. Prism never sees Kafka events, and in this setup the Microcks REST mock answered with its example response without checking the request body. That is why the gate runs both.

The event change is the rename from part 1, on an event: the provider renamed a field its consumer reads, and the gate failed before the change could ship. Both checks catch code that drifts from its contract. A change to a contract file itself needs its own check against the previous version, such as oasdiff for OpenAPI files.

Let AI Explain the Failure

A red gate tells the team that a contract broke; a Testkube AI agent tells them which one. The agent's instructions are short: how the two checks work, where their evidence lives, and the shape of the answer. An AI agent trigger starts it whenever a workflow labeled mocks: gate finishes with a failed status, so nobody has to ask.

On the request change, the agent read the gate run, found the failed Prism check, and read its JUnit report and the authmock log. Its answer named:

  • Contract: auth-service.openapi.yaml, operation POST /authorize
  • Field: orderId, required in the request body and missing from the request
  • Service: order-service
  • Suggested fix: send orderId in the body of the call to POST /authorize

The answer as it appears in the AI Chats tab of the failed run:

Testkube AI agent answer in the AI Chats tab of the failed gate run, naming the broken contract, field and fix

The agent can use your own models: OpenAI, Azure OpenAI or any OpenAI-compatible service. It works within the permissions of the user, and tools that change something can require approval before they run (AI architecture).

Conclusion

Each branch of order-service was checked against its neighbors' contracts inside the cluster, and one gate gave the pipeline a single verdict. The request change failed in Prism, the event change failed in Microcks, and the gate failed both branches. A rename like the one in part 1 now fails on the branch that made it, not in production.

The Prism mocks and the Kafka brokers are Test Workflow services, so they start with each run and are gone when it ends. Any mock tool that runs in a container fits the same way, such as WireMock or MockServer. When the gate fails, an AI agent explains which contract broke and how to fix it.

Part 1 covers when contract testing pays off and how to roll it out across teams. Part 3 covers provider-side checks with oasdiff.

To run checks like these in your own cluster, start with Test Workflow services in the Testkube docs, or talk to the Testkube team about your setup.

Sources

Frequently Asked Questions

What is schema-driven contract testing?

In schema-driven contract testing, the provider keeps its contract as an API description, such as an OpenAPI file for a REST API or an AsyncAPI file for an event, in Git next to the code. Checks confirm that the real service matches that description, and the same files generate the mocks its consumers test against.

What is the difference between Prism and Microcks?

Prism turns an OpenAPI file into a mock server that validates every request it receives, but it speaks only HTTP. Microcks, a CNCF incubating project, mocks REST APIs from OpenAPI and also tests events on Kafka against an AsyncAPI file.

Why run both Prism and Microcks in the same gate?

In this setup neither tool caught both breaks. Prism never sees Kafka events, and the Microcks REST mock answered with its example response without checking the request body. A Testkube gate workflow runs both checks in parallel and fails when either one fails.

How do mocks run inside a Testkube Test Workflow?

Each mock is a service of the Test Workflow. A service runs in its own pod, is ready before the test step starts, and is gone when the run ends, so the mocks, a Kafka broker and the service under test start together on every change.

How does a failed contract check fail the CI build?

The pipeline runs testkube run testworkflow with the -f flag. The CLI follows the run and exits with code 1 when the run fails, so the CI build fails with it.

Does this catch changes to the contract file itself?

No. These checks catch code that drifts from its contract. A change to a contract file needs its own check against the previous version, such as oasdiff for OpenAPI files.

{{banner-get-started}}

Lakshmi Mukkur
Solutions Architect
Testkube
Read more from
Lakshmi Mukkur

About Testkube

Testkube is the open testing platform for AI-driven engineering teams. It runs tests directly in your Kubernetes clusters, works with any CI/CD system, and supports every testing tool your team uses. By removing CI/CD bottlenecks, Testkube helps teams ship faster with confidence.
Get Started with a trial to see Testkube in action.