Contract Testing with Plain-Text HTTP Specs: A Practical Guide

Contract Testing with Plain-Text HTTP Specs: A Practical Guide

Microservices break apart functionality and introduce integration failure as a first-class risk. When Team A changes an API endpoint, Team B's service breaks — in production, during an incident, after everyone has gone home. Contract testing exists to catch these failures before they reach production, by codifying the agreement between a provider and its consumers as an executable specification.

Most articles about contract testing lead with Pact, which is powerful but also heavyweight: a broker service, language-specific libraries, a multi-step workflow. For many teams — particularly those building smaller service meshes or working with external APIs they do not own — a lighter approach using plain-text HTTP specs delivers most of the value at a fraction of the complexity.

This post explains what contract testing is, how to implement a meaningful version of it using .http and .hurl files, how to validate contracts in CI, and when to reach for a heavier tool like Pact.

What Is Contract Testing?

A contract is the specification of how a provider (an API) and a consumer (a client of that API) interact. It answers: What requests does the consumer make? What responses does the provider guarantee?

Contract testing verifies that both sides honor the agreement:

  • Provider verification: The provider's actual implementation returns what the contract specifies when given the specified requests.
  • Consumer verification: The consumer correctly forms requests and handles the specified responses.

Without contract testing, teams rely on integration tests in a shared environment — which are slow, flaky, and provide feedback only after code is merged. Contract testing enables teams to work independently and catch breaking changes at the boundary before integration.

Why Plain-Text HTTP Specs?

Pact and similar tools introduce a broker service, language-specific SDKs, and a publish-verify workflow. For teams with three or four services, that infrastructure cost is real. Plain-text HTTP specs offer a lower-friction alternative:

  • Readable by anyone — product managers, QA engineers, and developers can all read and write .http files
  • Version-controlled — contracts live in the repository alongside the code they specify
  • No broker required — verification runs directly in CI
  • Tooling-agnostic — run with Hurl, httpyac, or any HTTP client
  • Diff-reviewable — contract changes appear in pull request diffs, triggering human review

The tradeoff: plain-text specs are less automated than Pact (no auto-generated consumer stubs, no bidirectional compatibility matrix). For simple service meshes, this is an acceptable tradeoff. For complex ecosystems with many consumers per provider, Pact's tooling pays off.

Structuring a Plain-Text Contract

A contract spec file describes a specific interaction: the request the consumer makes and the response the provider must return. Here is a contract for a user service written as a Hurl file:

# Contract: UserService v1
# Consumer: OrderService
# Agreed: 2025-11-01
# Owner: platform-team@example.com

# --- Interaction: Get user by ID ---
# Consumer calls this to validate user exists before creating an order
GET https://{{user_service_host}}/api/v1/users/{{test_user_id}}
Authorization: Bearer {{service_token}}
Accept: application/json

HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.id" isString
jsonpath "$.email" isString
jsonpath "$.status" == "active"
jsonpath "$.plan" isString
# NOTE: OrderService requires 'status' and 'plan' fields.
# Adding new fields is non-breaking. Removing or renaming these fields is breaking.

# --- Interaction: Get non-existent user ---
# Consumer expects 404 with specific error format when user does not exist
GET https://{{user_service_host}}/api/v1/users/nonexistent-user-id-00000
Authorization: Bearer {{service_token}}
Accept: application/json

HTTP 404
[Asserts]
jsonpath "$.error" isString
jsonpath "$.code" == "USER_NOT_FOUND"
# NOTE: OrderService reads 'code' field to distinguish 404 types.
# Do not change the 'code' value without updating OrderService.

Several conventions make this more useful as a real contract:

Annotate the consumer — Document which service generated each interaction. When multiple consumers depend on the same provider, this makes breaking change impact analysis tractable.

Note the fields that matter — Comments explaining why a field is required prevent the contract from being treated as boilerplate. When a developer considers removing a field, the comment tells them who will break.

Include error interactions — Consumer code handles error responses too. Contracts should specify the error format, not just the happy path.

Date and version the contract — When contracts change, knowing when the agreement was made and which version of the API it covers helps debug issues.

JSON Schema Validation in Contracts

For response body shapes that go beyond simple field checks, embed schema validation. httpyac supports this directly:

### Verify order response schema
# @name getOrder
GET {{orderServiceHost}}/api/v1/orders/{{testOrderId}}
Authorization: Bearer {{serviceToken}}

?? status == 200
?? body.id != null
?? body.status matches /^(pending|confirmed|shipped|delivered|cancelled)$/
?? body.items count >= 1
?? body.total > 0

{{
  // Validate full schema
  const schema = {
    type: 'object',
    required: ['id', 'status', 'items', 'total', 'createdAt', 'customerId'],
    properties: {
      id: { type: 'string', format: 'uuid' },
      status: { type: 'string', enum: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'] },
      items: {
        type: 'array',
        minItems: 1,
        items: {
          type: 'object',
          required: ['productId', 'quantity', 'price'],
          properties: {
            productId: { type: 'string' },
            quantity: { type: 'integer', minimum: 1 },
            price: { type: 'number', minimum: 0 }
          }
        }
      },
      total: { type: 'number', minimum: 0 },
      customerId: { type: 'string' },
      createdAt: { type: 'string', format: 'date-time' }
    }
  };

  // httpyac exposes Ajv if installed
  const Ajv = require('ajv');
  const ajv = new Ajv({ allErrors: true });
  const validate = ajv.compile(schema);
  const valid = validate(response.parsedBody);
  if (!valid) {
    throw new Error('Schema validation failed: ' + JSON.stringify(validate.errors, null, 2));
  }
  console.log('Schema validation passed');
}}

Schema validation catches structural regressions — a required field dropped from the response, a type changed from string to integer — that simple field assertions would miss.

Organizing Contracts in a Repository

For a provider-side contract test repository (where the provider owns and runs the contracts):

contracts/
├── README.md                        # how to run contract tests
├── consumers/
│   ├── order-service/
│   │   ├── user-service.hurl        # OrderService → UserService contract
│   │   └── product-service.hurl     # OrderService → ProductService contract
│   ├── notification-service/
│   │   └── user-service.hurl        # NotificationService → UserService contract
│   └── payment-service/
│       └── order-service.hurl       # PaymentService → OrderService contract
├── environments/
│   ├── staging.env
│   └── production.env
└── fixtures/
    ├── test-user.json
    └── test-order.json

An alternative is consumer-owned contracts — each consuming service keeps its contract files in its own repository and runs provider verification as part of its CI. This matches how Pact works and scales better for large teams.

For small teams, provider-owned contracts in a shared contracts/ directory in a monorepo or a dedicated contracts repository work well and are simpler to manage.

Seeding Test Data

Contract tests need predictable test data to assert against. A few patterns:

Static test fixtures — Create long-lived test accounts in a non-production environment specifically for contract testing. Never delete them. Reference them by hardcoded IDs in the contract files. Simple and reliable.

Setup scripts — A pre-test script creates the necessary data and exports IDs as variables:

#!/bin/bash
<span class="hljs-comment"># setup-contract-data.sh
<span class="hljs-comment"># Creates test user and exports ID for contract test use

RESPONSE=$(curl -s -X POST <span class="hljs-string">"$USER_SERVICE_HOST/api/v1/users" \
  -H <span class="hljs-string">"Authorization: Bearer $ADMIN_TOKEN" \
  -H <span class="hljs-string">"Content-Type: application/json" \
  -d <span class="hljs-string">'{"email": "contract-test@example.com", "status": "active", "plan": "pro"}')

<span class="hljs-built_in">export TEST_USER_ID=$(<span class="hljs-built_in">echo <span class="hljs-variable">$RESPONSE <span class="hljs-pipe">| jq -r <span class="hljs-string">'.id')
<span class="hljs-built_in">echo <span class="hljs-string">"Test user ID: $TEST_USER_ID"

Provider test state — Some providers expose a /test/setup endpoint that creates predictable state for testing. This is a clean solution but requires the provider to support it.

CI Integration for Contract Tests

Run contract tests as a separate CI stage that gates deployment:

# GitHub Actions — Provider verification
name: Contract Tests
on:
  push:
    branches: [main]
  pull_request:
    paths:
      - 'src/**'
      - 'contracts/**'

jobs:
  contract-verification:
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4

      - name: Install Hurl
        run: |
          curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl_linux_amd64.tar.gz
          tar -xzf hurl_linux_amd64.tar.gz && sudo mv hurl /usr/local/bin/

      - name: Deploy to staging
        run: ./deploy.sh staging

      - name: Seed contract test data
        env:
          USER_SERVICE_HOST: ${{ secrets.STAGING_USER_SERVICE_HOST }}
          ADMIN_TOKEN: ${{ secrets.STAGING_ADMIN_TOKEN }}
        run: |
          source ./contracts/scripts/setup-contract-data.sh
          echo "TEST_USER_ID=$TEST_USER_ID" >> $GITHUB_ENV

      - name: Run provider contract verification
        env:
          USER_SERVICE_HOST: ${{ secrets.STAGING_USER_SERVICE_HOST }}
          SERVICE_TOKEN: ${{ secrets.STAGING_SERVICE_TOKEN }}
          TEST_USER_ID: ${{ env.TEST_USER_ID }}
        run: |
          hurl --variable user_service_host=$USER_SERVICE_HOST \
               --variable service_token=$SERVICE_TOKEN \
               --variable test_user_id=$TEST_USER_ID \
               --report-junit contract-results.xml \
               contracts/consumers/**/*.hurl

      - name: Publish results
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: contract-results.xml
          check_name: Contract Tests

A key practice: block deployment if contract tests fail. A contract test failure means a consumer will break in production. That is not a warning — it is a hard stop. Configure your CI pipeline to treat contract test failures as deployment blockers.

Versioning and Breaking Change Management

The hardest part of contract testing is managing breaking changes intentionally. Plain-text contracts make this visible in pull requests, but the process needs to be explicit:

Adding fields: Non-breaking. Consumers ignore unknown fields. No contract update required.

Removing or renaming required fields: Breaking. Update the contract, notify consumers, coordinate deployment order (provider deploys first with backward compatibility, then consumers update, then deprecated fields are removed).

Changing field types: Breaking. Same coordination required.

Changing status codes: Breaking, often subtle. A 200 becoming 201, or a 400 becoming 422.

A useful convention: when a contract file changes in a pull request, the PR description must include a migration note explaining how consumers should adapt. Code review serves as the coordination mechanism.

When to Use Pact Instead

Plain-text HTTP spec contracts work well for:

  • Teams with 2-8 services
  • APIs you do not control (external APIs, third-party services)
  • Incremental adoption of contract testing
  • Teams where non-developers need to read contracts

Reach for Pact when:

  • Many consumers depend on the same provider (Pact's compatibility matrix is invaluable)
  • You need auto-generated consumer stubs for isolated consumer testing
  • Your teams deploy independently and need automated compatibility verification without human coordination
  • You have dedicated platform engineering capacity to run and maintain the Pact broker

Contract Testing and UI Test Coverage

Contract tests verify the HTTP boundary between services — they do not cover what happens after a user clicks a button, how the UI handles error responses from the API, or whether the complete user journey works end to end.

For those scenarios, browser-level testing handles what contracts cannot. HelpMeTest generates Robot Framework and Playwright tests from natural language descriptions, covering end-to-end user flows in the cloud. When used alongside contract tests, you get both layers: API boundary correctness from contract specs, and real user experience validation from browser automation. HelpMeTest's Pro plan is $100/month.

The two layers are complementary. A contract test confirms the order service receives the correct user data format. A browser test confirms the checkout flow actually completes from a user's perspective.

Conclusion

Plain-text HTTP contract specs lower the barrier to contract testing significantly. They require no new infrastructure, live in version control, are readable by the whole team, and integrate naturally into existing CI pipelines that already run Hurl or httpyac.

Start with your highest-risk service boundary — the one that, when broken, causes the most production incidents. Write a contract file for it, add it to CI, and block deployments when it fails. That single change reduces the risk of breaking production through uncoordinated API changes.

Add contracts for additional boundaries as your team develops the habit. The files accumulate over time into a living specification of how your services interact — one that stays accurate because CI enforces it on every deployment.

Read more