Contract Test Generator

SkillDev tools

Generate consumer-driven contract tests using Pact framework to verify API provider-consumer compatibility and prevent integration breaking changes

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Contract Test Generator skill

What this skill tells your AI

The instructions your AI receives, as published by pramoddutta/qaskills in seed-skills/contract-test-generator/SKILL.md and read by ahel’s review.

Contract testing bridges the gap between unit tests and full integration tests by verifying that services can communicate correctly without requiring all services to be running simultaneously. Consumer-driven contract testing, pioneered by the Pact framework, inverts the traditional approach: consumers define what they expect from providers, and providers verify they can satisfy those expectations. This skill guides AI coding agents through generating robust contract tests that catch integration breaking changes before they reach production.

Core Principles

  1. Consumer-Driven Design: Consumers define the contract based on what they actually use, not what the provider offers. This ensures contracts are minimal, focused, and reflect real usage patterns rather than hypothetical API surfaces.

  2. Provider Verification Independence: Provider tests verify contracts independently without needing the consumer running. This decouples deployment schedules and enables teams to work autonomously while maintaining integration guarantees.

  3. Contract as Shared Artifact: The contract (pact file) serves as a living specification between consumer and provider. It is versioned, stored centrally, and referenced by both sides during their respective CI pipelines.

  4. Minimal Assertion Surface: Contracts should assert only what the consumer needs, not the full provider response. Testing for specific fields rather than entire response bodies prevents brittle contracts that break on harmless provider changes.

  5. Versioning Alignment with Deployability: Every contract must be associated with a specific consumer version and verified against a specific provider version. The combination of these versions determines whether a deployment is safe.

  6. Fail-Fast in CI: Contract verification failures must block deployments. The can-i-deploy tool provides a definitive answer about deployment safety based on the latest verification matrix.

  7. Incremental Adoption: Contract tests can be introduced for the most critical interactions first, then expanded. There is no requirement to cover every endpoint immediately; focus on high-risk integration points.

Project Structure

project-root/
├── consumer/
│   ├── src/
│   │   ├── api-client.ts
│   │   └── types.ts
│   ├── tests/
│   │   └── contract/
│   │       ├── user-service.consumer.pact.ts
│   │       ├── order-service.consumer.pact.ts
│   │       └── helpers/
│   │           ├── pact-setup.ts
│   │           └── matchers.ts
│   ├── pacts/                          # Generated pact files (JSON)
│   │   └── consumer-user_service.json
│   └── pact-config.ts
├── provider/
│   ├── src/
│   │   └── controllers/
│   ├── tests/
│   │   └── contract/
│   │       ├── provider-verification.pact.ts
│   │       └── state-handlers/
│   │           ├── user-states.ts
│   │           └── order-states.ts
│   └── pact-config.ts
├── pact-broker/
│   └── docker-compose.yml
└── ci/
    ├── publish-pacts.sh
    ├── verify-provider.sh
    └── can-i-deploy.sh

Consumer Test Generation

Basic Consumer Test in TypeScript

Consumer tests define the expectations a consumer has of a provider API. The Pact mock server simulates the provider during consumer tests.

// consumer/tests/contract/user-service.consumer.pact.ts
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import { resolve } from 'path';
import { UserApiClient } from '../../src/api-client';

const { like, eachLike, regex, integer, string, timestamp } = MatchersV3;

const provider = new PactV4({
  consumer: 'frontend-app',
  provider: 'user-service',
  dir: resolve(__dirname, '../../pacts'),
  logLevel: 'warn',
});

describe('User Service Contract', () => {
  describe('GET /api/users/:id', () => {
    it('returns a user when one exists', async () => {
      await provider
        .addInteraction()
        .given('a user with ID 42 exists')
        .uponReceiving('a request for user 42')
        .withRequest('GET', '/api/users/42', (builder) => {
          builder.headers({
            Accept: 'application/json',
            Authorization: regex(/^Bearer\s[\w-]+\.[\w-]+\.[\w-]+$/, 'Bearer eyJ.test.token'),
          });
        })
        .willRespondWith(200, (builder) => {
          builder.headers({ 'Content-Type': 'application/json' });
          builder.jsonBody({
            id: integer(42),
            name: string('Jane Doe'),
            email: regex(/^[\w.]+@[\w.]+\.\w+$/, 'jane@example.com'),
            role: regex(/^(admin|user|moderator)$/, 'user'),
            createdAt: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2024-01-15T10:30:00.000Z'),
            preferences: like({
              theme: string('dark'),
              notifications: like(true),
            }),
          });
        })
        .executeTest(async (mockServer) => {
          const client = new UserApiClient(mockServer.url);
          const user = await client.getUser(42);

          expect(user.id).toBe(42);
          expect(user.name).toBeDefined();
          expect(user.email).toContain('@');
        });
    });

    it('returns 404 when user does not exist', async () => {
      await provider
        .addInteraction()
        .given('no user with ID 999 exists')
        .uponReceiving('a request for non-existent user 999')
        .withRequest('GET', '/api/users/999', (builder) => {
          builder.headers({
            Accept: 'application/json',
            Authorization: regex(/^Bearer\s[\w-]+\.[\w-]+\.[\w-]+$/, 'Bearer eyJ.test.token'),
          });
        })
        .willRespondWith(404, (builder) => {
          builder.headers({ 'Content-Type': 'application/json' });
          builder.jsonBody({
            error: string('Not Found'),
            message: string('User with ID 999 not found'),
          });
        })
        .executeTest(async (mockServer) => {
          const client = new UserApiClient(mockServer.url);
          await expect(client.getUser(999)).rejects.toThrow('User not found');
        });
    });
  });

  describe('POST /api/users', () => {
    it('creates a new user', async () => {
      await provider
        .addInteraction()
        .given('the user creation endpoint is available')
        .uponReceiving('a request to create a user')
        .withRequest('POST', '/api/users', (builder) => {
          builder.headers({
            'Content-Type': 'application/json',
            Authorization: regex(/^Bearer\s.+$/, 'Bearer eyJ.admin.token'),
          });
          builder.jsonBody({
            name: string('John Smith'),
            email: regex(/^[\w.]+@[\w.]+\.\w+$/, 'john@example.com'),
            role: regex(/^(admin|user|moderator)$/, 'user'),
          });
        })
        .willRespondWith(201, (builder) => {
          builder.headers({ 'Content-Type': 'application/json' });
          builder.jsonBody({
            id: integer(1),
            name: string('John Smith'),
            email: string('john@example.com'),
            role: string('user'),
            createdAt: timestamp("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2024-06-01T12:00:00.000Z'),
          });
        })
        .executeTest(async (mockServer) => {
          const client = new UserApiClient(mockServer.url);
          const created = await client.createUser({
            name: 'John Smith',
            email: 'john@example.com',
            role: 'user',
          });

          expect(created.id).toBeDefined();
          expect(created.name).toBe('John Smith');
        });
    });
  });
});

Consumer Test in Java

// consumer/src/test/java/com/example/contract/UserServiceConsumerPactTest.java
package com.example.contract;

import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
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.consumer.MockServer;
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 static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service", port = "8080")
public class UserServiceConsumerPactTest {

    @Pact(consumer = "order-service")
    public V4Pact getUserPact(PactDslWithProvider builder) {
        return builder
            .given("a user with ID 42 exists")
            .uponReceiving("a request for user 42")
                .path("/api/users/42")
                .method("GET")
                .headers("Accept", "application/json")
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body(new PactDslJsonBody()
                    .integerType("id", 42)
                    .stringType("name", "Jane Doe")
                    .stringMatcher("email", "^[\\w.]+@[\\w.]+\\.\\w+$", "jane@example.com")
                    .stringType("role", "user")
                    .object("address")
                        .stringType("city", "Portland")
                        .stringType("state", "OR")
                    .closeObject()
                )
            .toPact(V4Pact.class);
    }

    @Test
    @PactTestFor(pactMethod = "getUserPact")
    void testGetUser(MockServer mockServer) {
        UserApiClient client = new UserApiClient(mockServer.getUrl());
        User user = client.getUser(42);

        assertThat(user.getId()).isEqualTo(42);
        assertThat(user.getName()).isNotBlank();
        assertThat(user.getEmail()).contains("@");
    }
}

Provider Verification Setup

TypeScript Provider Verification

// provider/tests/contract/provider-verification.pact.ts
import { Verifier } from '@pact-foundation/pact';
import { resolve } from 'path';
import { app } from '../../src/app';
import { Server } from 'http';

let server: Server;
const PORT = 8081;

beforeAll(async () => {
  server = app.listen(PORT);
});

afterAll(async () => {
  server.close();
});

describe('Provider Verification', () => {
  it('validates the expectations of all consumers', async () => {
    const verifier = new Verifier({
      providerBaseUrl: `http://localhost:${PORT}`,
      provider: 'user-service',

      // Option A: Verify from Pact Broker
      pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: process.env.CI === 'true',
      providerVersion: process.env.GIT_COMMIT_SHA,
      providerVersionBranch: process.env.GIT_BRANCH,

      // Enable pending pacts (new contracts won't break provider builds)
      enablePending: true,
      includeWipPactsSince: '2024-01-01',

      // State handlers set up test data for each provider state
      stateHandlers: {
        'a user with ID 42 exists': async () => {
          await seedDatabase({
            users: [{ id: 42, name: 'Jane Doe', email: 'jane@example.com', role: 'user' }],
          });
        },
        'no user with ID 999 exists': async () => {
          await clearDatabase('users');
        },
        'the user creation endpoint is available': async () => {
          await clearDatabase('users');
          await resetSequences('users');
        },
      },

      // Request filter to add auth headers the provider requires
      requestFilter: (req, _res, next) => {
        req.headers['Authorization'] = `Bearer ${generateTestToken()}`;
        next();
      },
    });

    await verifier.verifyProvider();
  });
});

Java Provider Verification

@Provider("user-service")
@PactBroker(
    url = "${PACT_BROKER_BASE_URL}",
    authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserServiceProviderPactTest {

    @LocalServerPort
    private int port;

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

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

    @State("a user with ID 42 exists")
    void userExists() {
        userRepository.save(new User(42L, "Jane Doe", "jane@example.com", "user"));
    }

    @State("no user with ID 999 exists")
    void userDoesNotExist() {
        userRepository.deleteAll();
    }
}

Pact Broker Integration

Docker Compose Setup

# pact-broker/docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: pact_broker
      POSTGRES_USER: pact_broker
      POSTGRES_PASSWORD: pact_broker_password
    volumes:
      - pact-db:/var/lib/postgresql/data

  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact_broker:pact_broker_password@postgres/pact_broker
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: admin
      PACT_BROKER_ALLOW_PUBLIC_READ: "true"
      PACT_BROKER_WEBHOOK_SCHEME_WHITELIST: https
      PACT_BROKER_CHECK_FOR_POTENTIAL_DUPLICATE_PACTICIPANT_NAMES: "true"
    depends_on:
      - postgres

volumes:
  pact-db:

Publishing Pacts to Broker

// scripts/publish-pacts.ts
import { Publisher } from '@pact-foundation/pact';
import { resolve } from 'path';
import { execSync } from 'child_process';

const gitCommitSha = execSync('git rev-parse HEAD').toString().trim();
const gitBranch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();

async function publishPacts() {
  const publisher = new Publisher({
    pactBroker: process.env.PACT_BROKER_BASE_URL || 'http://localhost:9292',
    pactBrokerToken: process.env.PACT_BROKER_TOKEN,
    pactFilesOrDirs: [resolve(__dirname, '../pacts')],
    consumerVersion: gitCommitSha,
    branch: gitBranch,
    tags: [gitBranch],
    buildUrl: process.env.CI_BUILD_URL,
  });

  await publisher.publishPacts();
  console.log('Pacts published successfully');
}

publishPacts().catch((err) => {
  console.error('Failed to publish pacts:', err);
  process.exit(1);
});

Can-I-Deploy Workflow

The can-i-deploy tool queries the Pact Broker to determine whether it is safe to deploy a particular version of an application.

#!/bin/bash
# ci/can-i-deploy.sh

PACTICIPANT=$1
VERSION=$(git rev-parse HEAD)
ENVIRONMENT=${2:-production}

echo "Checking if $PACTICIPANT version $VERSION can be deployed to $ENVIRONMENT..."

pact-broker can-i-deploy \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --to-environment "$ENVIRONMENT" \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-token "$PACT_BROKER_TOKEN" \
  --retry-while-unknown 30 \
  --retry-interval 10

if [ $? -eq 0 ]; then
  echo "Safe to deploy. Recording deployment..."
  pact-broker record-deployment \
    --pacticipant "$PACTICIPANT" \
    --version "$VERSION" \
    --environment "$ENVIRONMENT" \
    --broker-base-url "$PACT_BROKER_BASE_URL" \
    --broker-token "$PACT_BROKER_TOKEN"
else
  echo "BLOCKED: Cannot deploy $PACTICIPANT to $ENVIRONMENT"
  exit 1
fi

GitHub Actions CI Pipeline

# .github/workflows/contract-tests.yml
name: Contract Tests
on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:contract:consumer
      - name: Publish pacts
        if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
        run: npx ts-node scripts/publish-pacts.ts
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

  provider-verification:
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:contract:provider
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT_SHA: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}

  can-i-deploy:
    runs-on: ubuntu-latest
    needs: [consumer-tests, provider-verification]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: |
          docker run --rm \
            pactfoundation/pact-cli:latest \
            pact-broker can-i-deploy \
            --pacticipant frontend-app \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ secrets.PACT_BROKER_BASE_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

Webhook-Triggered Verification

Configure Pact Broker webhooks to trigger provider verification whenever a consumer publishes a new pact.

// scripts/setup-webhooks.ts
import axios from 'axios';

const BROKER_URL = process.env.PACT_BROKER_BASE_URL;
const BROKER_TOKEN = process.env.PACT_BROKER_TOKEN;

async function createWebhook() {
  await axios.post(
    `${BROKER_URL}/webhooks`,
    {
      description: 'Trigger provider verification on new pact',
      events: [
        { name: 'contract_content_changed' },
        { name: 'contract_requiring_verification_published' },
      ],
      request: {
        method: 'POST',
        url: 'https://api.github.com/repos/OWNER/REPO/dispatches',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/vnd.github.v3+json',
          Authorization: 'Bearer ${user.githubToken}',
        },
        body: {
          event_type: 'pact_changed',
          client_payload: {
            pact_url: '${pactbroker.pactUrl}',
            consumer: '${pactbroker.consumerName}',
            provider: '${pactbroker.providerName}',
            consumer_version: '${pactbroker.consumerVersionNumber}',
          },
        },
      },
    },
    {
      headers: {
        Authorization: `Bearer ${BROKER_TOKEN}`,
        'Content-Type': 'application/json',
      },
    }
  );
}

Pending Pacts and WIP Pacts

Pending pacts prevent new consumers from breaking existing provider builds. WIP (Work in Progress) pacts allow verification of contracts from feature branches.

// provider/pact-config.ts
export const providerVerificationConfig = {
  // Pending pacts: new contracts won't fail the provider build
  // Once a pact is successfully verified, it transitions out of pending
  enablePending: true,

  // WIP pacts: include pacts from consumer feature branches
  // Only pacts published after this date are considered
  includeWipPactsSince: '2024-01-01',

  // Consumer version selectors determine which pacts to verify
  consumerVersionSelectors: [
    { mainBranch: true },                    // Pacts from consumers' main branch
    { deployedOrReleased: true },            // Pacts from currently deployed consumers
    { matchingBranch: true },                // Pacts from same-named feature branch
    { branch: 'develop' },                   // Always verify develop branch pacts
  ],
};

Bi-Directional Contract Testing

Bi-directional contract testing allows providers to publish their own OpenAPI specification rather than running consumer pact tests directly. The Pact Broker compares the consumer pact with the provider specification.

// provider/scripts/publish-provider-contract.ts
import { execSync } from 'child_process';

const gitSha = execSync('git rev-parse HEAD').toString().trim();
const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();

// Publish the provider's OpenAPI spec as its contract
execSync(`
  pactflow publish-provider-contract \
    ./openapi/user-service.yaml \
    --provider user-service \
    --provider-app-version ${gitSha} \
    --branch ${branch} \
    --content-type application/yaml \
    --verification-exit-code 0 \
    --verification-results ./test-results/provider-tests.json \
    --verification-results-content-type application/json \
    --verifier pactflow-self-verification \
    --broker-base-url ${process.env.PACT_BROKER_BASE_URL} \
    --broker-token ${process.env.PACT_BROKER_TOKEN}
`);

GraphQL Contract Testing

// consumer/tests/contract/graphql.consumer.pact.ts
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, string, integer } = MatchersV3;

const provider = new PactV4({
  consumer: 'graphql-client',
  provider: 'graphql-gateway',
});

describe('GraphQL Contract', () => {
  it('fetches user with orders via GraphQL', async () => {
    await provider
      .addInteraction()
      .given('user 42 has orders')
      .uponReceiving('a GraphQL query for user with orders')
      .withRequest('POST', '/graphql', (builder) => {
        builder.headers({ 'Content-Type': 'application/json' });
        builder.jsonBody({
          query: string(`
            query GetUser($id: ID!) {
              user(id: $id) {
                id
                name
                orders {
                  id
                  total
                  status
                }
              }
            }
          `),
          variables: like({ id: '42' }),
        });
      })
      .willRespondWith(200, (builder) => {
        builder.jsonBody({
          data: {
            user: {
              id: string('42'),
              name: string('Jane Doe'),
              orders: eachLike({
                id: string('order-1'),
                total: like(99.99),
                status: string('SHIPPED'),
              }),
            },
          },
        });
      })
      .executeTest(async (mockServer) => {
        const client = new GraphQLClient(mockServer.url + '/graphql');
        const result = await client.query(GET_USER_WITH_ORDERS, { id: '42' });
        expect(result.data.user.orders.length).toBeGreaterThan(0);
      });
  });
});

Message-Based Contract Testing

For asynchronous event-driven systems, message pact testing validates the structure of messages published to queues or topics.

// consumer/tests/contract/order-events.consumer.pact.ts
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
const { like, string, integer, timestamp, regex } = MatchersV3;

const provider = new PactV4({
  consumer: 'notification-service',
  provider: 'order-service',
});

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
224
Forks
27
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
contract-test-generator
Source
github.com/pramoddutta/qaskills