·

API test generation and contract testing

API test generation and contract testing

How to Generate API Test Cases from OpenAPI Specs or Postman Collections with AI?

OpenAPI specs and Postman collections are the richest possible source material for AI-assisted API test generation — they encode your endpoints, request schemas, response schemas, authentication requirements, and example values in a machine-readable format. AI can read this structure and derive a comprehensive test suite far faster than manual authoring.

Feeding an OpenAPI Spec to AI

The key challenge with OpenAPI specs is size. A spec for a non-trivial API can be thousands of lines. You have two options:

Option 1: Provide the full spec — works well for specs under ~4,000 lines. Paste the entire YAML or JSON into your prompt and ask AI to derive tests from it.

Option 2: Extract the relevant paths — for large specs, extract only the endpoints relevant to your testing session using a tool like yq or openapi-cli:

yq '.paths["/api/v1/orders"]' openapi.yaml

yq '.paths | with_entries(select(.key | test("^/api/v1/orders")))' openapi.yaml

Then paste the extracted section into your prompt.

The Generation Prompt Structure for OpenAPI

You are generating API tests for our REST API. Use the OpenAPI spec section below as your source of truth.

TARGET FRAMEWORK: Pytest + requests (Python 3.11)
TEST FILE LOCATION: tests/api/test_orders.py
AUTHENTICATION: Bearer token. Use fixture `auth_headers` which returns {"Authorization": "Bearer {token}"}
BASE URL: Configured in conftest.py as `base_url` fixture

OPENAPI SPEC (relevant paths):
[PASTE SPEC HERE]

GENERATE:
1. One test function per endpoint+method combination
2. At minimum: one happy-path test and one validation-error test per endpoint
3. Use descriptive test function names: test_create_order_returns_201_with_valid_payload
4. Assert: HTTP status code, response Content-Type header, and key response body fields
5. For POST/PUT endpoints: assert the Location or id field in the response
6. Group tests in a class per endpoint resource

OUTPUT FORMAT: Complete, runnable pytest file with imports and fixtures.

Generating Tests from a Postman Collection

Postman collections have a different structure but are equally useful. Prompt AI to treat the collection as the source:

I'm providing a Postman collection (v2.1 format). Convert the requests and example responses 
into a complete Pytest test suite.

RULES:
- Each Postman request becomes at least one test function
- Use the Postman example responses as the basis for assertions
- If the collection has Postman test scripts (pm.test), preserve their assertion logic
  in the equivalent pytest assertion
- Convert Postman environment variables ({{base_url}}, {{auth_token}}) to pytest fixtures

[PASTE POSTMAN JSON HERE]

Extracting the Component Schema for Better Assertions

OpenAPI's components/schemas section defines the exact shape of request and response bodies. Include the relevant schemas alongside the path spec to generate schema-validated tests:

In addition to testing status codes, generate JSON schema validation assertions using 
jsonschema.validate(). Use the response schema from the OpenAPI components/schemas section 
below to validate the full response body structure.

COMPONENTS/SCHEMAS:
[PASTE RELEVANT SCHEMAS HERE]

This produces tests that assert not just that the response has a 200 status, but that every required field is present and typed correctly — catching silent contract breaks before clients notice.

Learning Tip: Treat your OpenAPI spec as a living test generator. Every time the spec changes (a new endpoint, a modified schema), re-run the generation prompt against the changed sections. Your AI-generated test suite should always be in sync with your spec. Set up a simple script that runs the generation prompt against your spec diff on PR — in 12 months this habit will have prevented dozens of undocumented API breaks.


How to Generate Positive, Negative, and Boundary API Tests in a Single Prompt?

Most developers write happy-path API tests and stop. AI can systematically generate the full test matrix — positive, negative, boundary — but only if your prompt explicitly requests each category with guidance on what to generate.

The Test Matrix Prompt

For the endpoint: POST /api/v1/users

REQUEST SCHEMA:
{
  "email": "string, required, must be valid email format, max 255 chars",
  "password": "string, required, min 8 chars, max 128 chars, must contain uppercase + number",
  "role": "string, optional, enum: [user, admin, viewer], defaults to 'user'",
  "display_name": "string, optional, max 100 chars"
}

Generate a complete pytest test class with THREE categories of tests:

CATEGORY 1 — POSITIVE TESTS:
- Valid minimal payload (required fields only)
- Valid full payload (all fields)
- Role variations: each valid enum value
- Max-length values for all string fields

CATEGORY 2 — NEGATIVE TESTS (validation errors, expect 400/422):
- Missing required field: email
- Missing required field: password
- Invalid email format (malformed string)
- Password too short (7 chars)
- Invalid role value (not in enum)
- Extra unknown field in payload (should reject or ignore — assert one behavior)

CATEGORY 3 — BOUNDARY TESTS:
- Email at exact 255 chars
- Email at 256 chars (over limit)
- Password at exact 8 chars (minimum)
- Password at 7 chars (one under minimum)
- Password at exact 128 chars (maximum)
- display_name at exact 100 chars
- display_name at 101 chars (over limit)

This prompt reliably produces 15-20 test cases in a single run. Here's what a representative slice of the output should look like:

import pytest
import requests

class TestCreateUser:
    ENDPOINT = "/api/v1/users"

    # ---- POSITIVE TESTS ----

    def test_create_user_minimal_payload_returns_201(self, base_url, auth_headers):
        payload = {"email": "[email protected]", "password": "ValidPass1"}
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code == 201
        assert response.headers["Content-Type"] == "application/json"
        body = response.json()
        assert "id" in body
        assert body["email"] == "[email protected]"
        assert body["role"] == "user"  # default value

    def test_create_user_full_payload_returns_201(self, base_url, auth_headers):
        payload = {
            "email": "[email protected]",
            "password": "ValidPass1",
            "role": "admin",
            "display_name": "Full User"
        }
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code == 201
        body = response.json()
        assert body["role"] == "admin"
        assert body["display_name"] == "Full User"

    # ---- NEGATIVE TESTS ----

    def test_create_user_missing_email_returns_422(self, base_url, auth_headers):
        payload = {"password": "ValidPass1"}
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code == 422
        errors = response.json()["errors"]
        assert any(e["field"] == "email" for e in errors)

    def test_create_user_invalid_email_format_returns_400(self, base_url, auth_headers):
        payload = {"email": "not-an-email", "password": "ValidPass1"}
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code in (400, 422)

    # ---- BOUNDARY TESTS ----

    def test_create_user_password_at_minimum_length_returns_201(self, base_url, auth_headers):
        payload = {"email": "[email protected]", "password": "Abc1efg8"}  # exactly 8 chars
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code == 201

    def test_create_user_password_below_minimum_returns_422(self, base_url, auth_headers):
        payload = {"email": "[email protected]", "password": "Abc1ef7"}  # 7 chars
        response = requests.post(f"{base_url}{self.ENDPOINT}", json=payload, headers=auth_headers)
        assert response.status_code == 422

Generating HTTP Method Matrix Tests

For CRUD endpoints, prompt for the full HTTP method matrix:

For the resource /api/v1/articles/{id}, generate tests for:
- GET (single resource): found, not found (404), unauthorized (401)
- PUT (full update): success, partial payload (validation error), not found, unauthorized
- PATCH (partial update): success, invalid field type, not found, unauthorized  
- DELETE: success (204 no content), not found, unauthorized, already deleted (idempotency)

For each combination, include the expected HTTP status code and response body assertion.

Learning Tip: When generating boundary tests, explicitly tell AI to use exact boundary values (length - 1, length, length + 1) rather than "a long string" or "a short string." AI often generates plausible but imprecise boundaries unless instructed otherwise. For numeric fields, always request min-1, min, min+1, max-1, max, and max+1 values explicitly.


How to Use AI to Generate Consumer-Driven Contract Tests?

Consumer-driven contract testing (CDCT) with Pact is the approach where API consumers define what they expect from providers, and those expectations become the provider's test suite. AI excels at generating both the consumer-side pact definitions and the provider-side verification tests — as long as you provide it with the right context about both sides of the contract.

Generating Consumer Pact Definitions

The consumer context AI needs:

Generate a Pact consumer test for our frontend React application consuming the /api/v1/products endpoint.

CONSUMER: frontend-app
PROVIDER: product-service
PACT FRAMEWORK: @pact-foundation/pact (JavaScript/TypeScript)
TEST RUNNER: Jest

WHAT THE CONSUMER ACTUALLY USES from the product response:
- id: number
- name: string
- price: number (displayed to 2 decimal places)
- stock_status: "in_stock" | "out_of_stock" | "discontinued"
- thumbnail_url: string (URL)

NOTE: The consumer does NOT use description, tags, category_id, weight, or dimensions.
Only assert on fields the consumer actually reads.

INTERACTIONS TO GENERATE:
1. GET /api/v1/products — returns list of products (happy path)
2. GET /api/v1/products/{id} — returns single product (found)
3. GET /api/v1/products/{id} — product not found (404)
4. GET /api/v1/products?category=electronics — filtered list

The output should use Pact matchers (not exact values) for fields that vary:

import { Pact, Matchers } from '@pact-foundation/pact';
const { like, eachLike, term } = Matchers;

describe('ProductService Consumer', () => {
  const provider = new Pact({
    consumer: 'frontend-app',
    provider: 'product-service',
    port: 8080,
  });

  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  describe('GET /api/v1/products', () => {
    beforeEach(() =>
      provider.addInteraction({
        state: 'products exist',
        uponReceiving: 'a request for all products',
        withRequest: { method: 'GET', path: '/api/v1/products' },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: eachLike({
            id: like(1),
            name: like('Widget Pro'),
            price: like(29.99),
            stock_status: term({ generate: 'in_stock', matcher: 'in_stock|out_of_stock|discontinued' }),
            thumbnail_url: like('https://cdn.example.com/images/widget-pro.jpg'),
          }),
        },
      })
    );

    it('returns a list of products', async () => {
      const products = await productService.getAll();
      expect(products[0]).toHaveProperty('id');
      expect(products[0]).toHaveProperty('price');
    });
  });
});

Generating Provider Pact Verification Tests

Once consumer pacts exist, AI can generate the provider verification setup:

Generate the Pact provider verification test for product-service.

PROVIDER FRAMEWORK: Node.js / Express
TEST RUNNER: Jest
PACT BROKER URL: https://pact.company.internal
PROVIDER NAME: product-service

STATE HANDLERS NEEDED:
- "products exist": seed 3 products in test DB using our ProductFactory
- "product {id} exists": seed one product with the given ID
- "product {id} does not exist": ensure no product with that ID

DATABASE: We use a test Postgres instance. Connection is available via `testDb` exported from 
tests/helpers/db.ts. Use ProductFactory from tests/factories/product.factory.ts.

Generate the full Jest test file with provider verification and all state handlers.

Learning Tip: When generating consumer pact tests, instruct AI to use Pact matchers (like, eachLike, term) instead of literal values. Contracts with literal values fail every time the provider updates its test data — but literal values are exactly what AI generates by default. Making this explicit in your prompt is the single change that moves Pact from "constantly breaking" to "reliably useful."


How to Generate Test Cases for Authentication, Pagination, and Error-Handling with AI?

These three concerns appear in nearly every API and require their own test patterns. AI can generate solid coverage for each if given explicit specifications of the expected behavior.

Authentication Test Generation

Authentication tests cover a range of scenarios that go beyond "valid token works, invalid token doesn't." Give AI the full matrix:

Generate pytest tests for authentication on our API. 

AUTHENTICATION MECHANISM: JWT Bearer tokens (RS256, 1-hour expiry)
AUTH ENDPOINTS:
- POST /api/v1/auth/login → returns { access_token, refresh_token, expires_in }
- POST /api/v1/auth/refresh → accepts { refresh_token } → returns new access_token
- POST /api/v1/auth/logout → invalidates refresh token

GENERATE TESTS FOR THESE SCENARIOS:
1. Login
   - Valid credentials → 200 + tokens
   - Wrong password → 401 with error code "INVALID_CREDENTIALS"
   - Non-existent user → 401 (same error, no user enumeration)
   - Account locked → 403 with error code "ACCOUNT_LOCKED"
   - Missing password field → 422

2. Protected Endpoint Access (/api/v1/profile)
   - Valid token → 200
   - No token (missing Authorization header) → 401
   - Malformed token (not valid JWT) → 401
   - Expired token (use a fixture that generates an expired JWT) → 401 with code "TOKEN_EXPIRED"
   - Token signed with wrong key → 401
   - Valid token but wrong role (viewer accessing admin endpoint) → 403

3. Token Refresh
   - Valid refresh token → 200 + new access_token
   - Reused refresh token (use same token twice) → 401 (token rotation)
   - Expired refresh token → 401

4. Logout
   - Successful logout → 204
   - Using refresh token after logout → 401

Pagination Test Generation

Generate pytest tests for paginated API responses.

PAGINATION SCHEME: Cursor-based pagination
ENDPOINT: GET /api/v1/events
RESPONSE SCHEMA:
{
  "data": [...],
  "pagination": {
    "next_cursor": "string | null",
    "prev_cursor": "string | null",
    "has_next_page": boolean,
    "has_prev_page": boolean,
    "total_count": integer
  }
}

GENERATE TESTS FOR:
1. First page (no cursor) → returns first {limit} items, next_cursor present, prev_cursor null
2. Second page (using next_cursor from page 1) → returns correct offset items
3. Last page → next_cursor is null, has_next_page is false
4. Invalid cursor value → 400 with descriptive error
5. limit parameter: default value, min value (1), max value (100), over max (should clamp or 422)
6. Cursor from deleted item → graceful handling (not a 500 error)

SEED DATA REQUIREMENT: Each test should seed its own events using EventFactory to guarantee 
predictable pagination behavior. Don't rely on existing database state.

Error Response Structure Tests

Generate tests that verify our API error response structure is consistent across all error types.

EXPECTED ERROR RESPONSE SCHEMA (all 4xx and 5xx must conform to this):
{
  "error": {
    "code": "string (machine-readable, SCREAMING_SNAKE_CASE)",
    "message": "string (human-readable)",
    "details": "object | null (field-level errors for validation failures)",
    "trace_id": "string (UUID format for correlation)"
  }
}

TEST APPROACH:
For each of these error conditions on endpoint POST /api/v1/orders:
- 400 Bad Request (malformed JSON body)
- 401 Unauthorized (no token)
- 403 Forbidden (wrong role)
- 404 Not Found (product ID in payload doesn't exist)
- 422 Unprocessable Entity (validation failure)
- 429 Too Many Requests (trigger rate limit)

Assert that:
1. The response body matches the schema above (use jsonschema.validate())
2. `error.code` is a non-empty string in SCREAMING_SNAKE_CASE
3. `error.message` is a non-empty human-readable string
4. `error.trace_id` is a valid UUID
5. No stack traces or internal paths are leaked in any error field

The last assertion — checking that no stack traces are leaked — is a security-relevant test that AI will only generate if you explicitly ask for it. Always include it in error response tests.

Combining Authentication, Pagination, and Error Handling in One Suite

For a complete API test file covering all three concerns:

import pytest
import requests
import re
from jsonschema import validate

ERROR_SCHEMA = {
    "type": "object",
    "required": ["error"],
    "properties": {
        "error": {
            "type": "object",
            "required": ["code", "message", "trace_id"],
            "properties": {
                "code": {"type": "string", "pattern": "^[A-Z_]+$"},
                "message": {"type": "string", "minLength": 1},
                "trace_id": {
                    "type": "string",
                    "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
                }
            }
        }
    }
}

class TestAPIErrorStructure:
    def test_unauthorized_request_returns_structured_error(self, base_url):
        response = requests.get(f"{base_url}/api/v1/profile")
        assert response.status_code == 401
        validate(instance=response.json(), schema=ERROR_SCHEMA)
        assert "stack" not in str(response.json())
        assert "/src/" not in str(response.json())

    def test_rate_limit_response_has_retry_after_header(self, base_url, auth_headers):
        # Trigger rate limit by sending 101 requests
        for _ in range(100):
            requests.get(f"{base_url}/api/v1/events", headers=auth_headers)
        response = requests.get(f"{base_url}/api/v1/events", headers=auth_headers)
        assert response.status_code == 429
        assert "Retry-After" in response.headers
        validate(instance=response.json(), schema=ERROR_SCHEMA)

Learning Tip: Always explicitly ask AI to assert what should NOT be in error responses — no stack traces, no internal file paths, no database error messages. AI naturally focuses on asserting what should be present; it rarely generates negative assertions unless asked. These absence-of-information assertions are the tests most likely to catch serious security issues in your error handling layer.