API Testing with Contract Tests (Pact): Stop Breaking Consumers Without Slowing Down

API Testing with Contract Tests (Pact): Stop Breaking Consumers Without Slowing Down

Most teams start API testing with a few happy-path integration tests. That helps—but it doesn’t answer the question that actually causes incidents:

“If we deploy this API change, will any consumer break?”

Contract testing is a practical way to catch breaking changes early, without needing a full end-to-end environment for every test run. In this hands-on guide, you’ll build a simple consumer-driven contract test using Pact, generate a contract file, and verify the API provider still satisfies it.

  • Consumer test: “When I call GET /users/:id, I expect a JSON body shaped like X.”
  • Contract (Pact file): A machine-readable record of that expectation.
  • Provider verification: The backend runs a verifier against the contract to confirm it still meets consumers’ expectations.

This approach is especially helpful when you have multiple clients (web, mobile, partners) and you want confidence to refactor without fear.


What You’ll Build

  • A minimal consumer test suite (Node + Jest) that generates a Pact contract.
  • A minimal provider API (Node + Express) that serves real responses.
  • A provider verification step that fails when the API breaks the contract.

You can adapt this pattern to other languages later—the workflow is the key.


Project Structure

Create a folder like this:

contract-testing-demo/ consumer/ package.json consumer.pact.test.js provider/ package.json server.js verify-pact.js pacts/ (generated) 

Step 1: Build the Provider API (Express)

Inside provider/:

cd provider npm init -y npm i express 

Create server.js:

const express = require("express"); function createApp() { const app = express(); // Example endpoint: GET /users/:id app.get("/users/:id", (req, res) => { const { id } = req.params; // In real life, you'd fetch from DB. Here we return a stable shape. res.json({ id: Number(id), name: "Ada Lovelace", email: "[email protected]", role: "admin", }); }); return app; } if (require.main === module) { const app = createApp(); const port = process.env.PORT || 4000; app.listen(port, () => console.log(`Provider listening on :${port}`)); } module.exports = { createApp }; 

Run it:

node server.js # open http://localhost:4000/users/1 

Step 2: Write a Consumer Contract Test (Pact + Jest)

Inside consumer/:

cd ../consumer npm init -y npm i -D jest @pact-foundation/pact 

Edit package.json to add a test script:

{ "name": "consumer", "version": "1.0.0", "type": "commonjs", "scripts": { "test": "jest" }, "devDependencies": { "@pact-foundation/pact": "^12.0.0", "jest": "^29.0.0" } } 

Create consumer.pact.test.js:

const path = require("path"); const { PactV3, MatchersV3 } = require("@pact-foundation/pact"); const { like, integer, email } = MatchersV3; // Consumer code: this is what your frontend/service would do. async function fetchUser(baseUrl, userId) { const res = await fetch(`${baseUrl}/users/${userId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } describe("Consumer contract: GET /users/:id", () => { const pact = new PactV3({ consumer: "web-frontend", provider: "users-api", dir: path.resolve(__dirname, "../pacts"), // write pact files here }); test("returns a user with required fields", async () => { // 1) Define the interaction (request + expected response) pact .given("User with id 1 exists") .uponReceiving("a request for user 1") .withRequest({ method: "GET", path: "/users/1", }) .willRespondWith({ status: 200, headers: { "Content-Type": "application/json" }, body: { id: integer(1), name: like("Ada Lovelace"), email: email("[email protected]"), role: like("admin"), }, }); // 2) Execute the test against Pact's mock server await pact.executeTest(async (mockServer) => { const user = await fetchUser(mockServer.url, 1); // Your consumer assertions can be lightweight; the contract is the real output. expect(user.id).toBe(1); expect(user).toHaveProperty("email"); }); }); }); 

Run the consumer test:

npm test 

If it passes, you should now have a generated contract file in pacts/, something like:

pacts/users-api-web-frontend.json 

This file is the “source of truth” for what the consumer expects.


Step 3: Verify the Provider Against the Contract

Now the backend proves it still satisfies that consumer expectation.

In provider/, install the verifier:

cd ../provider npm i -D @pact-foundation/pact 

Create verify-pact.js:

const path = require("path"); const http = require("http"); const { createApp } = require("./server"); const { Verifier } = require("@pact-foundation/pact"); async function main() { // Start the real provider server on an ephemeral port const app = createApp(); const server = http.createServer(app); await new Promise((resolve) => server.listen(0, resolve)); const port = server.address().port; const baseUrl = `http://127.0.0.1:${port}`; try { const pactPath = path.resolve(__dirname, "../pacts/users-api-web-frontend.json"); const result = await new Verifier({ provider: "users-api", providerBaseUrl: baseUrl, pactUrls: [pactPath], // Optional: if you need provider states, you can implement state handlers. // stateHandlers: { "User with id 1 exists": async () => {} }, }).verifyProvider(); console.log("Pact verification success:", result); } finally { server.close(); } } main().catch((err) => { console.error(err); process.exit(1); }); 

Add a script in provider/package.json:

{ "name": "provider", "version": "1.0.0", "main": "server.js", "scripts": { "start": "node server.js", "verify:pact": "node verify-pact.js" }, "dependencies": { "express": "^4.18.0" }, "devDependencies": { "@pact-foundation/pact": "^12.0.0" } } 

Run the provider verification:

npm run verify:pact 

If your provider response matches the contract (status, headers, and body shape), verification passes.


Step 4: See It Fail (On Purpose)

Let’s simulate a breaking change: rename email to emailAddress.

Edit provider/server.js and change the response:

res.json({ id: Number(id), name: "Ada Lovelace", emailAddress: "[email protected]", // BREAKING CHANGE role: "admin", }); 

Now rerun verification:

npm run verify:pact 

You should see a failure telling you the provider no longer satisfies the contract (the consumer expects email but the provider stopped sending it).

This is the “magic” of contract tests: you get a tight feedback loop on breaking changes without spinning up the whole consumer app.


How to Use This in Real Projects

  • Keep contracts versioned: store Pact files in a Pact Broker (recommended) or in a shared repo if you’re small.
  • Verify in CI: provider pipelines should fail if verification fails. Consumers should publish updated contracts when expectations change.
  • Use matching wisely: rely on matchers (like(), integer(), regex) to avoid brittle “exact value” contracts.
  • Model breaking vs non-breaking:
    • Adding an optional field is usually non-breaking.
    • Removing/renaming a field is breaking.
    • Changing a type (string → number) is breaking.
  • Provider states: if your endpoint depends on DB conditions, use Pact “provider states” to seed data (or stub the data layer) during verification.

Common Gotchas (and Practical Fixes)

  • “My provider returns extra fields—will this fail?”

    Usually not. Pact focuses on the fields the consumer cares about. That’s good: it reduces false failures.

  • “What about auth headers?”

    Add request headers in withRequest({ headers: ... }). For provider verification, point the verifier to a test environment or configure middleware to accept test tokens.

  • “My endpoint is non-deterministic (timestamps, IDs).”

    Use matchers: e.g. regex for timestamps, integer() for IDs. Contracts should define shape, not unstable values.

  • “We have multiple consumers.”

    That’s where Pact shines. Your provider verifies against all published consumer contracts before release.


A Simple Workflow You Can Adopt This Week

  • Consumers:
    • Write Pact tests around the API calls you actually use.
    • Generate and publish contract files whenever expectations change.
  • Provider:
    • Verify against the latest contracts on every PR.
    • Block merges/deploys if verification fails.

If you do just that, you’ll prevent a big class of “oops, mobile broke” or “frontend can’t render” releases—without turning your test suite into a slow end-to-end monster.


Next Steps

  • Add a second endpoint (e.g. POST /users) and model request bodies + validation errors in the contract.
  • Introduce provider states so you can test multiple scenarios (user exists vs not found).
  • Move contract storage to a Pact Broker and wire publish/verify into CI.

Contract testing won’t replace all integration tests, but it’s one of the highest ROI techniques for teams shipping APIs with real consumers. Once you try it on one endpoint, it’s hard to go back.


Leave a Reply

Your email address will not be published. Required fields are marked *