Testing

Contract Testing with Pact

A practical guide to consumer-driven contract testing with Pact for Node.js APIs covering provider verification, broker integration, and microservice testing workflows.

Contract Testing with Pact

Contract testing solves the integration testing problem for services that talk to each other. When Service A depends on Service B, how do you verify they work together without running both? Integration tests require both services running, are slow, and break for reasons unrelated to your code. Mocking Service B in Service A's tests is fast but proves nothing about the real API.

Contract tests split the problem in half. The consumer (Service A) writes a contract describing what it expects from the provider (Service B). The provider verifies it can fulfill that contract. Neither service needs the other running. Both teams know exactly what the other expects.

Pact is the most widely used contract testing framework. This guide covers the full workflow from consumer tests through provider verification and broker integration.

Prerequisites

  • Node.js installed (v16+)
  • Two or more services that communicate via HTTP APIs
  • Familiarity with testing concepts (Jest or Mocha)

How Pact Works

  1. Consumer test — The consumer writes a test describing the interaction: "When I call GET /users/1, I expect a 200 response with a JSON body containing id, name, and email."
  2. Pact file — Pact generates a contract file (JSON) from the consumer test.
  3. Provider verification — The provider runs the contract against its real implementation and verifies it returns what the consumer expects.
  4. Pact Broker — A central server stores contracts and verification results so both teams see the current state.
Consumer Test → Pact File (Contract) → Provider Verification
     ↓                    ↓                      ↓
"I expect this"    JSON contract file     "I can provide this"

Setup

# Consumer service
npm install --save-dev @pact-foundation/pact

# Provider service
npm install --save-dev @pact-foundation/pact

Consumer Side: Writing Contract Tests

The consumer defines what it expects from the provider.

The Consumer Code

// userClient.js — the consumer's HTTP client
var http = require("http");

function UserClient(baseUrl) {
  this.baseUrl = baseUrl;
}

UserClient.prototype.getUser = function(id, callback) {
  http.get(this.baseUrl + "/users/" + id, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode === 200) {
        callback(null, JSON.parse(data));
      } else if (res.statusCode === 404) {
        callback(null, null);
      } else {
        callback(new Error("API error: " + res.statusCode));
      }
    });
  }).on("error", callback);
};

UserClient.prototype.createUser = function(userData, callback) {
  var postData = JSON.stringify(userData);

  var options = {
    method: "POST",
    hostname: this.baseUrl.replace("http://", "").split(":")[0],
    port: this.baseUrl.split(":")[2],
    path: "/users",
    headers: {
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  var req = http.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode === 201) {
        callback(null, JSON.parse(data));
      } else {
        callback(new Error("Create failed: " + res.statusCode));
      }
    });
  });

  req.on("error", callback);
  req.write(postData);
  req.end();
};

module.exports = UserClient;

The Consumer Test

// userClient.pact.test.js
var path = require("path");
var { Pact } = require("@pact-foundation/pact");
var { Matchers } = require("@pact-foundation/pact");
var UserClient = require("./userClient");

var like = Matchers.like;
var eachLike = Matchers.eachLike;
var integer = Matchers.integer;
var string = Matchers.string;

var provider = new Pact({
  consumer: "UserWebApp",
  provider: "UserService",
  port: 1234,
  log: path.resolve(process.cwd(), "logs", "pact.log"),
  dir: path.resolve(process.cwd(), "pacts"),
  logLevel: "warn"
});

describe("User API Contract", function() {
  beforeAll(function() {
    return provider.setup();
  });

  afterAll(function() {
    return provider.finalize();
  });

  afterEach(function() {
    return provider.verify();
  });

  describe("GET /users/:id", function() {
    test("returns a user when one exists", function() {
      return provider.addInteraction({
        state: "a user with ID 1 exists",
        uponReceiving: "a request for user 1",
        withRequest: {
          method: "GET",
          path: "/users/1",
          headers: {
            "Accept": "application/json"
          }
        },
        willRespondWith: {
          status: 200,
          headers: {
            "Content-Type": "application/json"
          },
          body: {
            id: integer(1),
            name: string("Shane"),
            email: string("[email protected]"),
            createdAt: string("2026-01-15T12:00:00Z")
          }
        }
      }).then(function() {
        var client = new UserClient("http://localhost:1234");

        return new Promise(function(resolve, reject) {
          client.getUser(1, function(err, user) {
            if (err) return reject(err);
            expect(user).toBeTruthy();
            expect(user.id).toBe(1);
            expect(user.name).toBeTruthy();
            expect(user.email).toBeTruthy();
            resolve();
          });
        });
      });
    });

    test("returns 404 when user does not exist", function() {
      return provider.addInteraction({
        state: "no user with ID 999 exists",
        uponReceiving: "a request for non-existent user",
        withRequest: {
          method: "GET",
          path: "/users/999",
          headers: {
            "Accept": "application/json"
          }
        },
        willRespondWith: {
          status: 404,
          headers: {
            "Content-Type": "application/json"
          },
          body: {
            error: string("User not found")
          }
        }
      }).then(function() {
        var client = new UserClient("http://localhost:1234");

        return new Promise(function(resolve, reject) {
          client.getUser(999, function(err, user) {
            if (err) return reject(err);
            expect(user).toBeNull();
            resolve();
          });
        });
      });
    });
  });

  describe("POST /users", function() {
    test("creates a new user", function() {
      return provider.addInteraction({
        state: "the user database is available",
        uponReceiving: "a request to create a user",
        withRequest: {
          method: "POST",
          path: "/users",
          headers: {
            "Content-Type": "application/json"
          },
          body: {
            name: "New User",
            email: "[email protected]"
          }
        },
        willRespondWith: {
          status: 201,
          headers: {
            "Content-Type": "application/json"
          },
          body: {
            id: integer(),
            name: string("New User"),
            email: string("[email protected]"),
            createdAt: string()
          }
        }
      }).then(function() {
        var client = new UserClient("http://localhost:1234");

        return new Promise(function(resolve, reject) {
          client.createUser(
            { name: "New User", email: "[email protected]" },
            function(err, user) {
              if (err) return reject(err);
              expect(user.id).toBeTruthy();
              expect(user.name).toBe("New User");
              resolve();
            }
          );
        });
      });
    });
  });
});

Running this test generates a Pact file at pacts/userweb app-userservice.json.

Pact Matchers

Matchers define the shape of expected data without requiring exact values:

var { Matchers } = require("@pact-foundation/pact");

// Type matchers — value must be the same type
Matchers.like(42);              // Any integer
Matchers.like("hello");         // Any string
Matchers.like(true);            // Any boolean

// Specific type matchers
Matchers.integer(1);            // Any integer
Matchers.decimal(3.14);         // Any decimal number
Matchers.string("example");     // Any string
Matchers.boolean(true);         // Any boolean

// Regex matchers
Matchers.term({
  generate: "2026-01-15",
  matcher: "\\d{4}-\\d{2}-\\d{2}"
});

// Array matchers
Matchers.eachLike({
  id: Matchers.integer(),
  name: Matchers.string("item")
});
// Matches an array where each element has id (integer) and name (string)

// Nested object matching
Matchers.like({
  user: {
    id: Matchers.integer(1),
    profile: {
      bio: Matchers.string("A developer"),
      avatar: Matchers.term({
        generate: "https://example.com/avatar.png",
        matcher: "https?://.*"
      })
    }
  }
});

Provider Side: Verification

The provider runs the consumer's contract against its real implementation.

Provider Verification Test

// provider.pact.test.js
var { Verifier } = require("@pact-foundation/pact");
var path = require("path");
var app = require("./app"); // Your Express app

var server;

beforeAll(function(done) {
  server = app.listen(4000, done);
});

afterAll(function(done) {
  server.close(done);
});

describe("Provider Verification", function() {
  test("validates the contract with UserWebApp", function() {
    var opts = {
      provider: "UserService",
      providerBaseUrl: "http://localhost:4000",
      pactUrls: [
        path.resolve(process.cwd(), "pacts", "userweb app-userservice.json")
      ],
      stateHandlers: {
        "a user with ID 1 exists": function() {
          // Set up the provider state
          // Insert a test user into the database
          return db.insert("users", {
            id: 1,
            name: "Shane",
            email: "[email protected]",
            createdAt: new Date("2026-01-15T12:00:00Z")
          });
        },
        "no user with ID 999 exists": function() {
          // Ensure no user with this ID exists
          return db.deleteById("users", 999);
        },
        "the user database is available": function() {
          // Clear users table for clean state
          return db.clear("users");
        }
      }
    };

    return new Verifier(opts).verifyProvider();
  });
});

State Handlers

State handlers set up the provider's database to match the state described in the consumer test. Each consumer interaction declares a provider state like "a user with ID 1 exists". The provider must implement a handler for each state.

var stateHandlers = {
  "a user with ID 1 exists": function() {
    return seedDatabase([
      { id: 1, name: "Shane", email: "[email protected]" }
    ]);
  },

  "multiple users exist": function() {
    return seedDatabase([
      { id: 1, name: "Shane", email: "[email protected]" },
      { id: 2, name: "Alex", email: "[email protected]" },
      { id: 3, name: "Jordan", email: "[email protected]" }
    ]);
  },

  "no users exist": function() {
    return clearDatabase("users");
  },

  "the service is rate limited": function() {
    // Configure the provider to return 429
    return setRateLimit(0);
  }
};

Pact Broker

The Pact Broker is a central server that stores contracts and verification results. It replaces sharing Pact files via filesystem or Git.

Publishing Consumer Pacts

// publish-pacts.js
var publisher = require("@pact-foundation/pact-node");
var path = require("path");

var opts = {
  pactFilesOrDirs: [path.resolve(process.cwd(), "pacts")],
  pactBroker: "https://your-broker.pactflow.io",
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  consumerVersion: process.env.GIT_COMMIT || "1.0.0",
  tags: [process.env.GIT_BRANCH || "main"]
};

publisher.publishPacts(opts).then(function() {
  console.log("Pacts published successfully");
}).catch(function(err) {
  console.error("Failed to publish pacts:", err);
  process.exit(1);
});

Verifying from Broker

// provider-verify-broker.js
var { Verifier } = require("@pact-foundation/pact");

var opts = {
  provider: "UserService",
  providerBaseUrl: "http://localhost:4000",
  pactBrokerUrl: "https://your-broker.pactflow.io",
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  publishVerificationResult: true,
  providerVersion: process.env.GIT_COMMIT || "1.0.0",
  providerVersionTags: [process.env.GIT_BRANCH || "main"],
  stateHandlers: {
    // ... state handlers
  }
};

new Verifier(opts).verifyProvider().then(function() {
  console.log("Provider verification successful");
}).catch(function(err) {
  console.error("Provider verification failed:", err);
  process.exit(1);
});

Can I Deploy?

The Pact Broker tracks which consumer and provider versions are compatible. Use the can-i-deploy tool before deploying:

# Check if the consumer can deploy to production
npx pact-broker can-i-deploy \
  --pacticipant UserWebApp \
  --version $GIT_COMMIT \
  --to-environment production \
  --broker-base-url https://your-broker.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

This checks if all providers that UserWebApp depends on have successfully verified the contracts for this consumer version.

Multiple Consumer Scenario

When multiple consumers depend on the same provider:

OrderService (consumer) → UserService (provider)
NotificationService (consumer) → UserService (provider)
AdminDashboard (consumer) → UserService (provider)

Each consumer writes its own contract describing only the fields it uses. The provider verifies all contracts:

// OrderService only needs id and email
// Contract: { id: integer(), email: string() }

// AdminDashboard needs everything
// Contract: { id: integer(), name: string(), email: string(), role: string(), createdAt: string() }

The provider must satisfy all consumers. If it removes the role field, AdminDashboard's contract fails but OrderService's contract still passes. This makes breaking changes visible immediately.

Testing Async Interactions (Message Pacts)

For services that communicate via message queues:

// Consumer: expects to receive order events
var { MessageConsumerPact, synchronousBodyHandler } = require("@pact-foundation/pact");

var messagePact = new MessageConsumerPact({
  consumer: "NotificationService",
  dir: path.resolve(process.cwd(), "pacts"),
  provider: "OrderService"
});

describe("Order Event Contract", function() {
  test("processes order.created event", function() {
    return messagePact
      .given("an order has been created")
      .expectsToReceive("an order created event")
      .withContent({
        eventType: "order.created",
        orderId: Matchers.integer(1),
        userId: Matchers.integer(42),
        total: Matchers.decimal(99.99),
        items: Matchers.eachLike({
          productId: Matchers.integer(),
          quantity: Matchers.integer()
        })
      })
      .verify(synchronousBodyHandler(function(message) {
        // Process the message — verify the consumer can handle it
        var result = processOrderEvent(message);
        expect(result.notificationSent).toBe(true);
      }));
  });
});

Complete Working Example

Project Structure

user-web-app/           (consumer)
  src/
    userClient.js
  test/
    userClient.pact.test.js
  pacts/                 (generated)

user-service/            (provider)
  src/
    app.js
    routes/users.js
  test/
    provider.pact.test.js

Consumer Test (Complete)

// user-web-app/test/userClient.pact.test.js
var path = require("path");
var { Pact } = require("@pact-foundation/pact");
var { Matchers } = require("@pact-foundation/pact");
var UserClient = require("../src/userClient");

var provider = new Pact({
  consumer: "UserWebApp",
  provider: "UserService",
  port: 1234,
  dir: path.resolve(process.cwd(), "pacts"),
  logLevel: "warn"
});

describe("UserService Contract", function() {
  beforeAll(function() { return provider.setup(); });
  afterAll(function() { return provider.finalize(); });
  afterEach(function() { return provider.verify(); });

  test("get existing user", function() {
    return provider.addInteraction({
      state: "user 1 exists",
      uponReceiving: "get user 1",
      withRequest: { method: "GET", path: "/users/1" },
      willRespondWith: {
        status: 200,
        headers: { "Content-Type": "application/json" },
        body: {
          id: Matchers.integer(1),
          name: Matchers.string("Shane"),
          email: Matchers.string("[email protected]")
        }
      }
    }).then(function() {
      var client = new UserClient("http://localhost:1234");
      return new Promise(function(resolve, reject) {
        client.getUser(1, function(err, user) {
          if (err) return reject(err);
          expect(user.id).toBe(1);
          resolve();
        });
      });
    });
  });
});

Provider Verification (Complete)

// user-service/test/provider.pact.test.js
var { Verifier } = require("@pact-foundation/pact");
var path = require("path");
var app = require("../src/app");
var db = require("../src/db");

var server;

beforeAll(function(done) {
  server = app.listen(4000, done);
});

afterAll(function(done) {
  server.close(done);
});

test("verifies contract with UserWebApp", function() {
  return new Verifier({
    provider: "UserService",
    providerBaseUrl: "http://localhost:4000",
    pactUrls: [
      path.resolve(__dirname, "../../user-web-app/pacts/userweb app-userservice.json")
    ],
    stateHandlers: {
      "user 1 exists": function() {
        return db.seed([{ id: 1, name: "Shane", email: "[email protected]" }]);
      }
    }
  }).verifyProvider();
});

Common Issues and Troubleshooting

Consumer test fails with "No interaction found"

The mock server received a request that does not match any defined interaction:

Fix: Check that the request path, method, headers, and body in your interaction match exactly what the client sends. Enable debug logging (logLevel: "debug") to see what request was actually made.

Provider verification fails on state handler

The state handler throws an error or does not set up the correct data:

Fix: Verify the state handler name matches the consumer's state string exactly (case-sensitive). Ensure the database operations in the handler complete before returning. Return a Promise if the setup is async.

Pact file not found during provider verification

The file path is wrong or the consumer has not generated the Pact file:

Fix: Run the consumer tests first to generate the Pact file. Check the pactUrls path in the provider test. Use the Pact Broker to avoid filesystem path issues between repos.

Matcher too strict — provider returns slightly different data

Exact value matchers fail when the provider returns different but valid values:

Fix: Use Matchers.like() for type matching instead of exact values. Use Matchers.term() with regex for flexible string matching. Only assert on the fields the consumer actually uses.

Best Practices

  • Write consumer tests first. The consumer knows what it needs. The provider verifies it can deliver. This is consumer-driven design — the consumer drives the contract, not the provider.
  • Only contract what you use. If the consumer only uses id and email from a user object, only include those in the contract. Do not contract fields you might use someday.
  • Use matchers, not exact values. Type matchers (like, integer, string) make contracts flexible. Exact values make contracts brittle. The contract should verify structure, not specific data.
  • Keep provider states simple. Each state handler should do one thing: set up a specific data scenario. Complex state handlers are hard to maintain and debug.
  • Use the Pact Broker in production workflows. Sharing Pact files via filesystem works for learning but breaks down with multiple repos, teams, and environments.
  • Run can-i-deploy before deploying. This is the key benefit of contract testing — automated confidence that your deployment will not break other services.
  • Version your contracts. Use Git commit SHAs as consumer and provider versions. This creates a clear mapping between code versions and contract versions.
  • Do not use contract tests as integration tests. Contract tests verify the interface, not the business logic. You still need unit tests for logic and integration tests for end-to-end flows.

References

Powered by Contentful