home / skills / aj-geddes / useful-ai-prompts / api-contract-testing

api-contract-testing skill

/skills/api-contract-testing

This skill helps you validate API contracts and schemas across services, preventing breaking changes and ensuring consumer-provider compatibility.

npx playbooks add skill aj-geddes/useful-ai-prompts --skill api-contract-testing

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
17.2 KB
---
name: api-contract-testing
description: Verify API contracts between services to ensure compatibility and prevent breaking changes. Use for contract testing, Pact, API contract validation, schema validation, and consumer-driven contracts.
---

# API Contract Testing

## Overview

Contract testing verifies that APIs honor their contracts between consumers and providers. It ensures that service changes don't break dependent consumers without requiring full integration tests. Contract tests validate request/response formats, data types, and API behavior independently.

## When to Use

- Testing microservices communication
- Preventing breaking API changes
- Validating API versioning
- Testing consumer-provider contracts
- Ensuring backward compatibility
- Validating OpenAPI/Swagger specifications
- Testing third-party API integrations
- Catching contract violations in CI

## Key Concepts

- **Consumer**: Service that calls an API
- **Provider**: Service that exposes the API
- **Contract**: Agreement on API request/response format
- **Pact**: Consumer-defined expectations
- **Schema**: Structure definition (OpenAPI, JSON Schema)
- **Stub**: Generated mock from contract
- **Broker**: Central repository for contracts

## Instructions

### 1. **Pact for Consumer-Driven Contracts**

#### Consumer Test (Jest/Pact)
```typescript
// tests/pact/user-service.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserService } from '../../src/services/UserService';

const { like, eachLike, iso8601DateTimeWithMillis } = MatchersV3;

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
  port: 1234,
  dir: './pacts',
});

describe('User Service Contract', () => {
  const userService = new UserService('http://localhost:1234');

  describe('GET /users/:id', () => {
    test('returns user when found', async () => {
      await provider
        .given('user with ID 123 exists')
        .uponReceiving('a request for user 123')
        .withRequest({
          method: 'GET',
          path: '/users/123',
          headers: {
            Authorization: like('Bearer token'),
          },
        })
        .willRespondWith({
          status: 200,
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            id: like('123'),
            email: like('[email protected]'),
            name: like('John Doe'),
            age: like(30),
            createdAt: iso8601DateTimeWithMillis('2024-01-01T00:00:00.000Z'),
            role: like('user'),
          },
        })
        .executeTest(async (mockServer) => {
          const user = await userService.getUser('123');

          expect(user.id).toBe('123');
          expect(user.email).toBeDefined();
          expect(user.name).toBeDefined();
        });
    });

    test('returns 404 when user not found', async () => {
      await provider
        .given('user with ID 999 does not exist')
        .uponReceiving('a request for non-existent user')
        .withRequest({
          method: 'GET',
          path: '/users/999',
        })
        .willRespondWith({
          status: 404,
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            error: like('User not found'),
            code: like('USER_NOT_FOUND'),
          },
        })
        .executeTest(async (mockServer) => {
          await expect(userService.getUser('999')).rejects.toThrow(
            'User not found'
          );
        });
    });
  });

  describe('POST /users', () => {
    test('creates new user', async () => {
      await provider
        .given('user does not exist')
        .uponReceiving('a request to create user')
        .withRequest({
          method: 'POST',
          path: '/users',
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            email: like('[email protected]'),
            name: like('New User'),
            age: like(25),
          },
        })
        .willRespondWith({
          status: 201,
          headers: {
            'Content-Type': 'application/json',
          },
          body: {
            id: like('new-123'),
            email: like('[email protected]'),
            name: like('New User'),
            age: like(25),
            createdAt: iso8601DateTimeWithMillis(),
            role: 'user',
          },
        })
        .executeTest(async (mockServer) => {
          const user = await userService.createUser({
            email: '[email protected]',
            name: 'New User',
            age: 25,
          });

          expect(user.id).toBeDefined();
          expect(user.email).toBe('[email protected]');
        });
    });
  });

  describe('GET /users/:id/orders', () => {
    test('returns user orders', async () => {
      await provider
        .given('user 123 has orders')
        .uponReceiving('a request for user orders')
        .withRequest({
          method: 'GET',
          path: '/users/123/orders',
          query: {
            limit: '10',
            offset: '0',
          },
        })
        .willRespondWith({
          status: 200,
          body: {
            orders: eachLike({
              id: like('order-1'),
              total: like(99.99),
              status: like('completed'),
              createdAt: iso8601DateTimeWithMillis(),
            }),
            total: like(5),
            hasMore: like(false),
          },
        })
        .executeTest(async (mockServer) => {
          const response = await userService.getUserOrders('123', {
            limit: 10,
            offset: 0,
          });

          expect(response.orders).toBeDefined();
          expect(Array.isArray(response.orders)).toBe(true);
          expect(response.total).toBeDefined();
        });
    });
  });
});
```

#### Provider Test (Verify Contract)
```typescript
// tests/pact/user-service.provider.test.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { app } from '../../src/app';
import { setupTestDB, teardownTestDB } from '../helpers/db';

describe('Pact Provider Verification', () => {
  let server;

  beforeAll(async () => {
    await setupTestDB();
    server = app.listen(3001);
  });

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

  test('validates the expectations of OrderService', () => {
    return new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: [
        path.resolve(__dirname, '../../pacts/orderservice-userservice.json'),
      ],
      // Provider state setup
      stateHandlers: {
        'user with ID 123 exists': async () => {
          await createTestUser({ id: '123', name: 'John Doe' });
        },
        'user with ID 999 does not exist': async () => {
          await deleteUser('999');
        },
        'user 123 has orders': async () => {
          await createTestUser({ id: '123' });
          await createTestOrder({ userId: '123' });
        },
      },
    })
      .verifyProvider()
      .then((output) => {
        console.log('Pact Verification Complete!');
      });
  });
});
```

### 2. **OpenAPI Schema Validation**

```typescript
// tests/contract/openapi.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import OpenAPIValidator from 'express-openapi-validator';
import fs from 'fs';
import yaml from 'js-yaml';

describe('OpenAPI Contract Validation', () => {
  let validator;

  beforeAll(() => {
    const spec = yaml.load(
      fs.readFileSync('./openapi.yaml', 'utf8')
    );

    validator = OpenAPIValidator.middleware({
      apiSpec: spec,
      validateRequests: true,
      validateResponses: true,
    });
  });

  test('GET /users/:id matches schema', async () => {
    const response = await request(app)
      .get('/users/123')
      .expect(200);

    // Validate against OpenAPI schema
    expect(response.body).toMatchObject({
      id: expect.any(String),
      email: expect.stringMatching(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/),
      name: expect.any(String),
      age: expect.any(Number),
      createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
    });
  });

  test('POST /users validates request body', async () => {
    const invalidUser = {
      email: 'invalid-email',  // Should fail validation
      name: 'Test',
    };

    await request(app)
      .post('/users')
      .send(invalidUser)
      .expect(400);
  });
});
```

### 3. **JSON Schema Validation**

```python
# tests/contract/test_schema_validation.py
import pytest
import jsonschema
from jsonschema import validate
import json

# Define schemas
USER_SCHEMA = {
    "type": "object",
    "required": ["id", "email", "name"],
    "properties": {
        "id": {"type": "string"},
        "email": {"type": "string", "format": "email"},
        "name": {"type": "string"},
        "age": {"type": "integer", "minimum": 0, "maximum": 150},
        "role": {"type": "string", "enum": ["user", "admin"]},
        "createdAt": {"type": "string", "format": "date-time"},
    },
    "additionalProperties": False
}

ORDER_SCHEMA = {
    "type": "object",
    "required": ["id", "userId", "total", "status"],
    "properties": {
        "id": {"type": "string"},
        "userId": {"type": "string"},
        "total": {"type": "number", "minimum": 0},
        "status": {
            "type": "string",
            "enum": ["pending", "paid", "shipped", "delivered", "cancelled"]
        },
        "items": {
            "type": "array",
            "items": {
                "type": "object",
                "required": ["productId", "quantity", "price"],
                "properties": {
                    "productId": {"type": "string"},
                    "quantity": {"type": "integer", "minimum": 1},
                    "price": {"type": "number", "minimum": 0},
                }
            }
        }
    }
}

class TestAPIContracts:
    def test_get_user_response_schema(self, api_client):
        """Validate user endpoint response against schema."""
        response = api_client.get('/api/users/123')

        assert response.status_code == 200
        data = response.json()

        # Validate against schema
        validate(instance=data, schema=USER_SCHEMA)

    def test_create_user_request_schema(self, api_client):
        """Validate create user request body."""
        valid_user = {
            "email": "[email protected]",
            "name": "Test User",
            "age": 30,
        }

        response = api_client.post('/api/users', json=valid_user)
        assert response.status_code == 201

        # Response should also match schema
        validate(instance=response.json(), schema=USER_SCHEMA)

    def test_invalid_request_rejected(self, api_client):
        """Invalid requests should be rejected."""
        invalid_user = {
            "email": "not-an-email",
            "age": -5,  # Invalid age
        }

        response = api_client.post('/api/users', json=invalid_user)
        assert response.status_code == 400

    def test_order_response_schema(self, api_client):
        """Validate order endpoint response."""
        response = api_client.get('/api/orders/order-123')

        assert response.status_code == 200
        validate(instance=response.json(), schema=ORDER_SCHEMA)

    def test_order_items_array_validation(self, api_client):
        """Validate nested array schema."""
        order_data = {
            "userId": "user-123",
            "items": [
                {"productId": "prod-1", "quantity": 2, "price": 29.99},
                {"productId": "prod-2", "quantity": 1, "price": 49.99},
            ]
        }

        response = api_client.post('/api/orders', json=order_data)
        assert response.status_code == 201

        result = response.json()
        validate(instance=result, schema=ORDER_SCHEMA)
```

### 4. **REST Assured for Java**

```java
// ContractTest.java
import io.restassured.RestAssured;
import io.restassured.module.jsv.JsonSchemaValidator;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

public class UserAPIContractTest {

    @Test
    public void getUserShouldMatchSchema() {
        given()
            .pathParam("id", "123")
        .when()
            .get("/api/users/{id}")
        .then()
            .statusCode(200)
            .body(JsonSchemaValidator.matchesJsonSchemaInClasspath("schemas/user-schema.json"))
            .body("id", notNullValue())
            .body("email", matchesPattern("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"))
            .body("age", greaterThanOrEqualTo(0));
    }

    @Test
    public void createUserShouldValidateRequest() {
        String userJson = """
            {
                "email": "[email protected]",
                "name": "Test User",
                "age": 30
            }
            """;

        given()
            .contentType("application/json")
            .body(userJson)
        .when()
            .post("/api/users")
        .then()
            .statusCode(201)
            .body("id", notNullValue())
            .body("email", equalTo("[email protected]"))
            .body("createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2}T.*"));
    }

    @Test
    public void getUserOrdersShouldReturnArray() {
        given()
            .pathParam("id", "123")
            .queryParam("limit", 10)
        .when()
            .get("/api/users/{id}/orders")
        .then()
            .statusCode(200)
            .body("orders", isA(java.util.List.class))
            .body("orders[0].id", notNullValue())
            .body("orders[0].status", isIn(Arrays.asList(
                "pending", "paid", "shipped", "delivered", "cancelled"
            )))
            .body("total", greaterThanOrEqualTo(0));
    }

    @Test
    public void invalidRequestShouldReturn400() {
        String invalidUser = """
            {
                "email": "not-an-email",
                "age": -5
            }
            """;

        given()
            .contentType("application/json")
            .body(invalidUser)
        .when()
            .post("/api/users")
        .then()
            .statusCode(400)
            .body("error", notNullValue());
    }
}
```

### 5. **Contract Testing with Postman**

```json
// postman-collection.json
{
  "info": {
    "name": "User API Contract Tests"
  },
  "item": [
    {
      "name": "Get User",
      "request": {
        "method": "GET",
        "url": "{{baseUrl}}/users/{{userId}}"
      },
      "test": "
        pm.test('Response status is 200', () => {
          pm.response.to.have.status(200);
        });

        pm.test('Response matches schema', () => {
          const schema = {
            type: 'object',
            required: ['id', 'email', 'name'],
            properties: {
              id: { type: 'string' },
              email: { type: 'string', format: 'email' },
              name: { type: 'string' },
              age: { type: 'integer' }
            }
          };

          pm.response.to.have.jsonSchema(schema);
        });

        pm.test('Email format is valid', () => {
          const data = pm.response.json();
          pm.expect(data.email).to.match(/^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$/);
        });
      "
    }
  ]
}
```

### 6. **Pact Broker Integration**

```yaml
# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - run: npm ci
      - run: npm run test:pact

      - name: Publish Pacts
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_BROKER_TOKEN }}

  provider-tests:
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - run: npm ci
      - run: npm run test:pact:provider

      - name: Can I Deploy?
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant=UserService \
            --version=${{ github.sha }} \
            --to-environment=production \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_BROKER_TOKEN }}
```

## Best Practices

### ✅ DO
- Test contracts from consumer perspective
- Use matchers for flexible matching
- Validate schema structure, not specific values
- Version your contracts
- Test error responses
- Use Pact broker for contract sharing
- Run contract tests in CI
- Test backward compatibility

### ❌ DON'T
- Test business logic in contract tests
- Hard-code specific values in contracts
- Skip error scenarios
- Test UI in contract tests
- Ignore contract versioning
- Deploy without contract verification
- Test implementation details
- Mock contract tests

## Tools

- **Pact**: Consumer-driven contracts (multiple languages)
- **Spring Cloud Contract**: JVM contract testing
- **OpenAPI/Swagger**: API specification and validation
- **Postman**: API contract testing
- **REST Assured**: Java API testing
- **Dredd**: OpenAPI/API Blueprint testing
- **Spectral**: OpenAPI linting

## Examples

See also: integration-testing, api-versioning-strategy, continuous-testing for comprehensive API testing strategies.

Overview

This skill verifies API contracts between services to ensure compatibility and prevent breaking changes. It provides patterns and examples for consumer-driven contracts (Pact), OpenAPI and JSON Schema validation, and language-specific test snippets. Use it to catch contract drift early and enforce stable integrations across teams.

How this skill works

The skill checks request and response shapes, types, status codes, and provider states against defined contracts. It includes consumer tests that generate pacts, provider verification that replays pact expectations, runtime OpenAPI validation middleware, and standalone JSON Schema checks executed in CI. Examples cover Jest/Pact, provider verifiers, express-openapi-validator, jsonschema (Python), and REST Assured (Java).

When to use it

  • When testing microservice communication and preventing breaking changes
  • To validate consumer-driven contracts using Pact between teams
  • To enforce request/response shapes against OpenAPI or JSON Schema
  • In CI pipelines to catch contract violations before deployment
  • When integrating third-party APIs and validating expectations

Best practices

  • Maintain consumer-generated pacts in a broker or versioned artifact store
  • Use provider state handlers to set deterministic data for verification
  • Validate both requests and responses in runtime and tests
  • Fail CI on contract mismatches to prevent breaking releases
  • Keep schemas strict for required fields and permissive where backward compatibility is needed

Example use cases

  • Consumer test that generates a pact for GET/POST user endpoints using Jest and Pact
  • Provider verification that loads pact files and runs against a test server with state setup
  • Express app middleware using express-openapi-validator to validate requests and responses
  • Python tests that validate API responses against JSON Schema with jsonschema
  • Java contract tests using REST Assured and JsonSchemaValidator for end-to-end schema checks

FAQ

Do I need both Pact and OpenAPI validation?

Use Pact for consumer-driven contracts where consumers define expectations; use OpenAPI for centralized, language-agnostic schema validation. They complement each other and can be used together.

Where should contract tests run?

Run contract tests in developer CI for fast feedback and in release pipelines to gate deployments. Provider verification is especially important in integration and staging pipelines.