top of page

Tutorial 10: API Contract Testing

  • Contributor
  • May 6
  • 3 min read

Integration tests pass. Then production breaks. Why? The contract changed silently. Contract tests prevent this.

Step 1: What a Contract Is (5 min)

The contract: "When client sends X, server returns Y."

X and Y are concrete: status code, headers, body shape, fields, types.

Contract test = automated check that both sides hold up their end.

Step 2: Why Integration Tests Aren't Enough (10 min)

Integration tests run against real services. They pass when:

  • Server returns valid data

  • Client handles that specific data

They miss:

  • Field renamed; client uses old name → still works in test because of test data

  • Server adds required field; old clients break in production

  • Server changes types (number → string); test data matches; real data doesn't

Contract tests pin down the shape.

Step 3: Pact for Consumer-Driven Contracts (15 min)

Consumer says: "I expect the server to return this."

// Client test
const provider = new Pact({ consumer: "WebApp", provider: "UserAPI" });

await provider.addInteraction({
  state: "user 123 exists",
  uponReceiving: "a request for user 123",
  withRequest: {
    method: "GET",
    path: "/users/123",
  },
  willRespondWith: {
    status: 200,
    body: {
      id: 123,
      name: like("Alice"),  // type matcher
      email: like("alice@example.com"),
    },
  },
});

// Run the test; verify the client handles this response

Result: a pact file webapp-userapi.json describing the contract.

Step 4: Provider Verifies the Pact (10 min)

// Server test
const verifier = new Verifier({
  providerBaseUrl: "http://localhost:3000",
  pactUrls: ["./pacts/webapp-userapi.json"],
});

await verifier.verifyProvider();

Server starts; replays each interaction; checks responses match the pact.

If the server changes the response shape, the verifier fails. Contract broken.

Step 5: Pact Broker (10 min)

Pact files live in a central broker:

# Consumer publishes pact
pact-broker publish ./pacts --broker-base-url http://broker.example.com

# Provider pulls pacts
pact-broker pacts-for-verification --provider UserAPI

The broker tracks which consumers depend on which version of the provider.

Step 6: Can-I-Deploy (10 min)

Before deploying:

pact-broker can-i-deploy --pacticipant UserAPI --version 1.5.0

Returns: yes/no, based on whether all consumers' contracts pass.

Integrate into CI:

- run: pact-broker can-i-deploy --pacticipant UserAPI --version ${{ github.sha }}

Block deploys that break consumers.

Step 7: Schema-Based Contracts (10 min)

Alternative: use OpenAPI as the contract.

# Generate mock server from spec
prism mock openapi.yaml

# Client tests run against the mock; spec is the contract

Server validates responses against the same spec in CI:

// Test
const response = await fetch("/users/123");
const valid = validateAgainstOpenAPI(response, "/users/{id}", "200");
expect(valid).toBe(true);

Less ceremony than Pact; works well when the schema is rich.

Step 8: Type-Generated Clients (5 min)

If both sides use generated code from the same spec, the type system enforces the contract:

// Generated from OpenAPI
const client = new UsersApi();
const user = await client.getUser({ id: 123 });
// user.name is typed; client breaks at compile time if server changes

Combined with openapi-validator middleware, you get end-to-end safety.

Step 9: Backward Compatibility Tests (10 min)

Test that v2 still serves v1 clients:

test("v2 server serves v1 client", async () => {
  const v1Pact = loadPact("client-v1-vs-server-v1.json");
  await verifyAgainstServer("http://server-v2:3000", v1Pact);
});

If v2 broke the v1 contract, the test catches it before deploy.

Step 10: When to Use Each (5 min)

  • Pact: distributed teams; many consumers; explicit contract per consumer.

  • OpenAPI + validators: single source; simpler; great when you control both sides.

  • Generated clients: strongest type safety; pairs well with OpenAPI.

Most teams start with OpenAPI + validators. Add Pact if you have many external consumers.

What You Just Did

Contract testing: pacts, schemas, can-i-deploy gates, generated clients. The thing that stops "I changed one field" from breaking production.

Common Failure Modes

No contract. "It worked in dev"; production breaks.

Brittle exact-match tests. Fail on any change, including additive ones.

No can-i-deploy gate. Provider deploys despite breaking consumers.

Hand-maintained contracts. Drift between test and reality.

Treating contract tests as e2e. They're shape tests, not behavior tests. Different role.

You're Done With Path 23

You can design REST and GraphQL APIs, version them safely, paginate, handle errors and auth, document with OpenAPI, and prevent regressions with contract tests.

Recommend Distributed Systems Hands-On — apply API design to multi-service systems.

Related reading

Keep learning. This article is part of the Test Automation path in the ShiftQuality Learning Center. Build test automation that lasts, with ROI you can defend.

bottom of page