HTTP Clients And APIs

Contract Testing Orientation

Contract tests check that two systems still agree on how they communicate. For APIs, the contract includes request shape, response shape, status codes, headers, authentication expectations, error formats, pagination rules, idempotency behavior, and versioning promises.

A provider can pass all of its unit tests while breaking a consumer. A consumer can pass all of its unit tests against mocks while no longer matching the real provider. Contract testing reduces that gap by making the boundary explicit and executable.

Provider And Consumer

The provider is the API, service, webhook sender, package, or component that exposes a contract. The consumer is the application, service, job, generated client, or partner that relies on it.

A consumer-driven contract starts with what the consumer actually needs. The provider then verifies that it can satisfy those expectations. This avoids a provider publishing a huge theoretical schema while consumers depend on a smaller but critical subset.

Provider-driven contracts are also common. A public API may publish OpenAPI and require every consumer to follow it. In that model, contract tests still matter: the provider verifies its implementation against the published contract, and consumers verify their client code against the same artifact.

What Contract Tests Protect

Imagine a consumer expects this response:

{
  "id": 123,
  "name": "Notebook"
}

If the provider changes name to title, the provider may still return valid JSON and pass its own business tests, but the consumer breaks.

PHP example
<?php

declare(strict_types=1);

function productContractErrors(array $response): array
{
    $errors = [];

    if (!array_key_exists('id', $response) || !is_int($response['id'])) {
        $errors[] = 'id must be an integer.';
    }

    if (!array_key_exists('name', $response) || !is_string($response['name'])) {
        $errors[] = 'name must be a string.';
    }

    return $errors;
}

foreach ([
    'matching' => ['id' => 123, 'name' => 'Notebook'],
    'breaking' => ['id' => 123, 'title' => 'Notebook'],
] as $label => $response) {
    $errors = productContractErrors($response);
    echo $label . ': ' . ($errors === [] ? 'ok' : implode('; ', $errors)) . PHP_EOL;
}

// Prints:
// matching: ok
// breaking: name must be a string.

Real tools provide stronger checks, but the principle is the same: the boundary expectation is asserted directly.

Contract Tests Are Not End-To-End Tests

End-to-end tests prove that a full journey works through several systems. They are valuable but slower, more fragile, and harder to diagnose.

Contract tests focus on one communication boundary. They should be fast enough to run in CI and precise enough to tell a provider which expectation changed.

They sit beside:

  • unit tests for internal rules
  • integration tests for database, queues, and framework wiring
  • API tests for real HTTP behavior
  • smoke tests after deployment
  • end-to-end tests for critical journeys

Do not replace all integration tests with contract tests. A response can match a schema while the provider saved the wrong database row or authorized the wrong tenant.

OpenAPI-Based Contract Checks

OpenAPI is often the contract artifact for HTTP APIs. Provider tests can send real requests to a test server and validate status, headers, and response body against the documented operation. Consumer tests can use the OpenAPI document to generate a client or validate fixtures.

OpenAPI works well for:

  • paths, methods, parameters, and request bodies
  • response status and body schemas
  • authentication schemes and documented headers
  • examples and generated documentation
  • breaking-change comparison between versions

It is weaker for behavior that is difficult to express in a schema: authorization ownership, idempotency storage, ordering guarantees, side effects, retry timing, and business workflow state. Cover those with targeted tests and prose contract rules.

Pact-Style Interaction Contracts

Pact-style tests describe interactions from the consumer's point of view: given provider state, when the consumer sends this request, the provider responds with this status, headers, and body shape.

This is useful when several consumers need different slices of a provider API. Each consumer publishes the interactions it depends on. The provider verifies all relevant interactions before deployment.

A consumer contract should not over-specify incidental details. If the consumer does not care about response header order, generated IDs, or optional fields, the contract should not make them brittle. Match on meaning and required fields.

Fixtures Must Be Representative

Fixtures are examples used by tests. Poor fixtures give false confidence.

Include:

  • happy-path response
  • validation error
  • authentication failure
  • authorization failure
  • not-found response
  • conflict or idempotency error
  • pagination metadata where relevant
  • null and missing optional fields
  • unknown optional response fields when clients should ignore them
  • realistic IDs, timestamps, enum values, and money formats

Do not use only a tiny success object if production responses include links, metadata, nested arrays, and nullable fields. Test fixtures should reflect the contract, not the easiest example to type.

Good consumer tests also prove tolerance where tolerance is part of the contract. If an API says clients must ignore unknown response fields, include a fixture with an extra harmless field and verify the consumer still works. If enum values may expand, decide whether the client should show an unknown-state fallback, reject the response, or require a new version. Without that decision, a provider can make a technically reasonable addition that still breaks a strict PHP match expression, backed enum mapping, or serializer configuration.

The reverse matters for requests. A provider should reject malformed or unsupported input clearly, but it should not depend on incidental JSON property order, whitespace, or optional fields being absent unless the contract says so. Contract tests are a good place to lock down these boundaries because they force the team to say which variation is allowed and which is a real compatibility break.

Provider State Matters

An interaction often depends on provider state: product exists, product missing, account lacks permission, idempotency key already used, rate limit exceeded. Contract tests need a controlled way to set those states.

For a PHP provider, this may mean database factories, fake external services, seeded records, or dedicated test hooks. Avoid tests that depend on production data or whatever was left in a shared staging environment.

Provider state names should be stable and understandable. product 123 exists is better than setup case seven.

Backwards Compatibility Rules

Usually compatible:

  • adding an optional response field
  • accepting a new optional request field
  • adding a new endpoint
  • adding a new error example for an error that already existed

Usually breaking:

  • removing or renaming a field
  • changing a field type
  • making an optional field required
  • changing status code semantics
  • removing an enum value
  • adding enum values when consumers are not prepared for unknown values
  • changing authentication, pagination, or idempotency rules

Contract tests should run before deployment, not after consumers report failure. Breaking-change tools can compare OpenAPI documents, but human review is still needed for semantic changes.

CI Placement

A practical CI flow is:

  1. consumer tests verify client code against its contract or generated client
  2. provider tests verify real responses against OpenAPI or Pact interactions
  3. a breaking-change check compares contract changes with the last released version
  4. generated clients or docs are rebuilt and checked for committed diffs
  5. provider deployment is blocked if required consumer contracts fail

For public APIs, also publish the contract artifact with the release. For internal services, make it clear which provider version has verified which consumer contracts.

Avoid Contract Test Theatre

A contract test that checks only 200 OK and one field is not enough for a payment API. A suite that validates every optional implementation detail can be too brittle. The contract should protect behavior that consumers rely on.

Warning signs:

  • fixtures are copied from old docs and never run against the provider
  • provider tests mock the provider instead of calling real HTTP behavior
  • consumers pin generated clients but never regenerate them in CI
  • tests cover only success responses
  • every contract failure is ignored as "just docs"
  • contract changes are reviewed by only the team making the change

Treat contract failures as design conversations. Sometimes the provider is wrong, sometimes the consumer over-specified, and sometimes the contract needs a versioned change.

Webhooks And Asynchronous Contracts

Contracts are not only for request-response APIs. Webhook senders and queue publishers also need executable expectations. A webhook contract should describe the event type, required identifiers, timestamp format, signature headers, retry expectations, duplicate delivery behavior, and example payloads. A queue contract should describe message envelope, schema version, ordering expectations, idempotency key, and dead-letter behavior.

Asynchronous contracts need extra care because the consumer may not fail immediately in front of a user. A renamed event field might sit in a queue until a worker deploys, then fail repeatedly. Contract tests can verify that a producer still emits the fields a worker needs and that a consumer can tolerate optional additions. They should also include duplicate and stale events where those are part of the delivery model.

What Contract Tests Cannot Prove

Contract tests cannot prove authorization is correct for every user, a workflow is profitable, a database transaction is isolated, a queue handler is idempotent, or a retry policy is operationally safe. They also cannot prove performance under load.

They can prove that the agreed boundary still looks and behaves as documented for representative interactions. That is enough to catch many expensive integration bugs earlier.

What To Check In A Project

Check which API boundaries need contract tests. Public APIs, cross-team services, generated clients, webhooks, and payment integrations deserve more ceremony than private single-team endpoints.

Check whether the contract source is OpenAPI, JSON Schema, Pact-style interactions, stored examples, or hand-written assertions.

Check that tests cover important error responses and not only successful responses.

Check provider states are controlled and deterministic.

Check contract changes are reviewed by provider and consumer owners.

Check CI runs contract checks before incompatible changes deploy.

What You Should Be Able To Do

After this lesson, you should be able to explain provider and consumer contracts, choose between OpenAPI-based and consumer-driven checks, identify breaking API changes, design representative fixtures, and place contract verification in CI.

You should also be able to name what contract tests cannot prove, so they strengthen the test strategy instead of becoming a false guarantee.

Practice

Task: Check A Response Contract

Write a small PHP script that checks whether a product API response matches the consumer's expected contract.

Requirements

  • Use declare(strict_types=1);.
  • Require id as an integer.
  • Require name as a string.
  • Include one matching response.
  • Include one breaking response where name is missing or renamed.
  • Return useful contract error messages.
  • Print both results.

Check Your Work

Run the script and confirm the breaking response fails even if it still looks like valid JSON data.

Show solution

This solution models the consumer's expectation as explicit assertions.

PHP example
<?php

declare(strict_types=1);

/**
 * @param array<string, mixed> $response
 * @return list<string>
 */
function productContractErrors(array $response): array
{
    $errors = [];

    if (!isset($response['id']) || !is_int($response['id'])) {
        $errors[] = 'id must be an integer.';
    }

    if (!isset($response['name']) || !is_string($response['name'])) {
        $errors[] = 'name must be a string.';
    }

    return $errors;
}

$matching = ['id' => 123, 'name' => 'Notebook'];
$breaking = ['id' => 123, 'title' => 'Notebook'];

foreach (['matching' => $matching, 'breaking' => $breaking] as $label => $response) {
    $errors = productContractErrors($response);
    echo $label . ': ' . ($errors === [] ? 'contract ok' : implode('; ', $errors)) . PHP_EOL;
}

// Prints:
// matching: contract ok
// breaking: name must be a string.

Why This Works

The breaking response is still valid data, but it no longer satisfies the consumer's contract. That is the kind of change contract testing is meant to catch.

Practice: Verify Provider Examples

Create a small contract verifier for product responses.

Task

Write a function that checks a response has:

  • integer id
  • non-empty string name
  • integer price_pennies greater than or equal to zero
  • string currency

Run it against one valid response, one response with renamed name, and one response with a negative price. Print useful errors for failures.

Show solution

The verifier checks the fields the consumer depends on.

PHP example
<?php

declare(strict_types=1);

function productResponseErrors(array $response): array
{
    $errors = [];

    if (!isset($response['id']) || !is_int($response['id'])) {
        $errors[] = 'id must be an integer';
    }

    if (!isset($response['name']) || !is_string($response['name']) || $response['name'] === '') {
        $errors[] = 'name must be a non-empty string';
    }

    if (!isset($response['price_pennies']) || !is_int($response['price_pennies']) || $response['price_pennies'] < 0) {
        $errors[] = 'price_pennies must be a non-negative integer';
    }

    if (!isset($response['currency']) || !is_string($response['currency'])) {
        $errors[] = 'currency must be a string';
    }

    return $errors;
}

$examples = [
    'valid' => ['id' => 1, 'name' => 'Notebook', 'price_pennies' => 1299, 'currency' => 'GBP'],
    'renamed' => ['id' => 1, 'title' => 'Notebook', 'price_pennies' => 1299, 'currency' => 'GBP'],
    'negative' => ['id' => 1, 'name' => 'Notebook', 'price_pennies' => -1, 'currency' => 'GBP'],
];

foreach ($examples as $label => $response) {
    $errors = productResponseErrors($response);
    echo $label . ': ' . ($errors === [] ? 'ok' : implode('; ', $errors)) . PHP_EOL;
}

// Prints:
// valid: ok
// renamed: name must be a non-empty string
// negative: price_pennies must be a non-negative integer

A real test could validate against OpenAPI or JSON Schema, but the goal is the same: catch provider output that no longer satisfies the consumer.

Practice: Plan Contract CI

Design CI for a PHP service that publishes OpenAPI and has two internal consumers.

Task

Write a plan that includes:

  • provider tests against real HTTP responses
  • consumer fixture or generated-client tests
  • breaking-change comparison
  • generated documentation/client freshness checks
  • provider states for missing resources and authorization failures
  • review ownership for contract changes

Name two failures this CI would catch and one important bug it would not prove impossible.

Show solution

Provider CI should start the API in a test environment, seed controlled provider states, call documented operations, and validate status, headers, and bodies against the OpenAPI document. It should cover success, validation failure, not found, authorization failure, and conflict paths where relevant.

Consumer CI should test client code against stored fixtures or a generated client built from the published contract. The build should regenerate docs and clients from a clean checkout and fail if committed generated files are stale. A breaking-change comparison should run against the last released OpenAPI document.

Provider states should have clear names such as product exists, product missing, and user lacks permission. Contract pull requests should be reviewed by the provider owner and at least one consumer owner when the endpoint is cross-team.

This CI would catch a renamed name field and an undocumented status-code change from 404 to 200 with an error body. It would not prove tenant authorization is correct for every data row; a schema-valid response can still contain the wrong customer's data.