DEV Community

Sir Max
Sir Max

Posted on

Stop Mocking Your APIs — A Practical Guide to Contract Testing That Actually Works

Stop Mocking Your APIs — A Practical Guide to Contract Testing That Actually Works

Last year, we shipped a feature that broke three downstream services. The unit tests passed. The integration tests passed. The mock server returned exactly what we expected. And yet, production was on fire at 2 PM on a Tuesday.

The problem? Our mock server was lying to us.

Here's what I learned, and how we fixed it.


The Mock Server Trap

Mocks are seductive. You write a test:

# test_user_service.py
mock_api.get("/users/42").returns({"id": 42, "name": "Alice", "role": "admin"})

result = user_service.get_user_name(42)
assert result == "Alice"  # ✅ PASS
Enter fullscreen mode Exit fullscreen mode

Everything is green. You deploy. Then you learn that the real API changed name to display_name last month, and nobody told you. Your mock was faithfully returning a field the API no longer sends.

Mocks test what you think the API looks like. They don't test what the API actually looks like.


What Contract Testing Actually Is

Contract testing verifies that two services agree on how they communicate. Think of it like an API prenup: both sides sign off on a shared understanding, and if either side wants to change it, the other gets notified.

There are two flavors:

Approach Works by Best for
Consumer-driven Consumer defines expectations, provider verifies them Internal microservices
Provider-driven Provider publishes a spec, consumers verify against it Public APIs, SDKs

I'll focus on consumer-driven because that's where mocks cause the most damage.


A Real Example: Payment Service

Imagine you're building an order service that calls a payment API. The payment team maintains their own codebase, and you have no control over it.

Step 1: The Consumer Defines What They Need

# order_service/tests/contract/test_payment_contract.py
import pytest
from pact import Consumer, Provider

pact = Consumer("OrderService").has_pact_with(Provider("PaymentService"))

def test_create_payment():
    # "Here's what I expect the payment API to return"
    expected = {
        "payment_id": "pay_abc123",
        "status": "captured",
        "amount": 2999,
        "currency": "usd"
    }

    (pact
        .given("a valid payment request")
        .upon_receiving("a POST to /payments")
        .with_request("POST", "/payments", body={
            "order_id": "order_555",
            "amount": 2999,
            "currency": "usd"
        })
        .will_respond_with(200, body=expected))

    with pact:
        result = create_payment("order_555", 2999, "usd")
        assert result["payment_id"] == "pay_abc123"
Enter fullscreen mode Exit fullscreen mode

This generates a pact file — a JSON document describing what OrderService expects from PaymentService.

Step 2: The Provider Verifies It Can Meet Those Expectations

# payment_service/tests/contract/test_provider.py
import pytest
from pact import Verifier

def test_payment_provider_contracts():
    verifier = Verifier(provider="PaymentService")

    # Run against the REAL PaymentService (not a mock)
    verifier.verify_pacts(
        "./pacts/orderservice-paymentservice.json",
        url="http://localhost:8080"  # ← The actual service
    )
Enter fullscreen mode Exit fullscreen mode

When this runs, it sends real HTTP requests to your running PaymentService. If the response doesn't match what OrderService expects, the test fails — before anyone deploys.

Step 3: Share the Pact

The pact file lives in a shared location (Pact Broker, or even a Git repo). Both teams run their contract tests in CI. When the Payment team wants to rename status to payment_status, they'll see:

FAILED: OrderService expects field 'status' in response body
Enter fullscreen mode Exit fullscreen mode

And they'll either coordinate the change with you or version the API.


Three Things I Wish I Knew When I Started

1. Contract Tests Are NOT a Replacement for E2E Tests

A contract test says "this specific interaction works." It does not say "the entire business flow works." You still need end-to-end tests for critical paths. Contract tests sit between unit tests and E2E tests on the testing pyramid.

     /\
    /E2E\       ← Few, slow, cover full flows
   /------\
  /Contract\    ← Medium count, verify API agreements
 /----------\
/   Unit     \  ← Many, fast, verify logic
--------------
Enter fullscreen mode Exit fullscreen mode

2. Start with Your Most Fragile Integration

Don't contract-test everything on day one. Pick the integration that breaks most often — in my case, it was the payment service. Write contracts for 2-3 key endpoints. Prove the value. Then expand.

We started with 3 contracts. Six months later we had 47. The number of "API changed without telling us" incidents dropped from roughly 2 per month to zero.

3. Pacts Are Not Versioned by Default — Handle It

If you store pact files in Git, tag them with provider versions. Otherwise, when the PaymentService team deploys v2, you're still verifying against v1 pacts from your CI cache.

# In CI, publish with a version tag
pact-broker publish ./pacts \
  --consumer-app-version=$(git rev-parse HEAD) \
  --branch=$(git rev-parse --abbrev-ref HEAD)
Enter fullscreen mode Exit fullscreen mode

The "Can I Deploy?" Question

This is where contract testing earns its keep. With a Pact Broker set up, you can ask:

https://broker.pact.io/can-i-deploy?pacticipant=PaymentService&version=abc123
Enter fullscreen mode Exit fullscreen mode

The broker checks: Does this version of PaymentService satisfy every consumer's expectations? If yes → safe to deploy. If no → here's who you'd break.

No more guessing.


When NOT to Use Contract Testing

  • You control both sides and can deploy them together. If you're a solo dev with two services in the same repo, contract testing adds overhead with little benefit.
  • The API is extremely stable. If you're consuming GitHub's v3 REST API, it hasn't changed in years. A simple mock is fine.
  • You're prototyping. Don't add contracts to a POC that might be deleted in two weeks.

Getting Started (In Under 30 Minutes)

# Install Pact for Python
pip install pact-python

# Or for Node.js
npm install @pact-foundation/pact

# Or for Go / Java / Ruby / Rust — Pact has bindings for everything
Enter fullscreen mode Exit fullscreen mode

Then write your first consumer test (5 minutes), run your provider against it (5 minutes), and share the pact file (2 minutes). The hardest part is convincing another team to run the provider verification in their CI — but once they see it catch a breaking change, they'll be on board.


What Changed for My Team

We went from:

  • "The payment API changed again and broke staging" — weekly occurrence
  • "Can we deploy? I don't know, check the mocks" — constant uncertainty
  • "The integration tests take 45 minutes" — E2E bloat

To:

  • Breaking API changes caught in CI, before merge
  • "Can I deploy?" answered by Pact Broker in 2 seconds
  • Integration test suite down to 12 minutes (contract tests replaced half the E2E scenarios)

No magic. Just agreeing on what the API should look like, and then verifying that agreement automatically.


What's your experience with API testing? Have you tried contract tests, or do you still rely on mocks? I'd love to hear what's worked (or failed spectacularly) in your stack.

Top comments (2)

Collapse
 
marcusykim profile image
Marcus Kim

The name-to-display_name example is exactly why mocks can give a team false confidence: both the code and its invented API agree with each other. I like your restraint around starting with two or three fragile endpoints rather than contract-testing everything. Going from roughly two silent breakages a month to zero, while cutting E2E time from 45 to 12 minutes, makes the tradeoff concrete. I would also make can-i-deploy part of the release checklist so the broker is not only collecting contracts but actually stopping incompatible versions.

Collapse
 
nark3d profile image
Adam Lewis

I've seen pacts sitting in a broker that nobody had run against the provider build for months, and everyone carried on assuming they were covered. If the verification isn't in the provider's own pipeline then it's not really doing anything for you. Worth putting it there rather than leaving it as a job someone has to remember. More on using the real dependency at the boundary here: prickles.org/tenet/real-dependency...