Guide

Adding contract tests with Pact to your API testing suite.

A practical guide for teams adding consumer-driven contract testing to an existing Node.js or JVM API suite — using @pact-foundation/pact with REST Assured or pact-jvm.

May 2026

See how Pactum and REST Assured compare across all 14 dimensions.

API testing libraries comparison →

// Why add contract tests?

Integration test suites catch breaking API changes — eventually. The problem is 'eventually.' A provider team merges a field rename on Tuesday; your consumer tests catch it on Thursday when the integration environment is ready; the release is blocked on Friday. Contract testing moves that catch to Tuesday, before the change ships, by letting consumers declare the exact contract they depend on and having providers verify it in CI.

The mechanism: consumer tests run against a mock provider, generate a contract file (a JSON document listing exact requests and expected responses), publish that file to a broker, and then the provider's CI pipeline verifies the real implementation against every published consumer contract. If a field is renamed or a status code changes, the provider's pipeline fails before anything reaches staging.

When it's worth it — and when it isn't

Contract testing adds tooling overhead. The payoff is proportional to your API surface and team structure. It earns its keep when multiple teams consume the same provider API — a mobile app, a web frontend, and a B2B integration all calling the same user service means any breaking change could affect three consumers. It also earns its keep when deploying provider and consumer changes independently, where you cannot rely on a shared integration environment to catch mismatches before production.

It's overkill when a single consumer owns a single provider, or when your entire stack is deployed together as a monolith. A team of five shipping a Rails app with an embedded API does not need a Pact Broker. Automated integration tests against a shared test environment are simpler, faster to write, and adequate for that context. This guide assumes you've already decided contract testing makes sense for your situation.

// Contract testing in 3 minutes

Two parties: the consumer (the code that calls an API) and the provider (the API server). The consumer writes tests that describe every interaction it depends on — 'I call GET /users/1 and I expect a 200 with an id and a name field.' Those tests run against a Pact mock provider that validates the consumer is actually making the calls it claims to make. After the tests pass, Pact generates a contract file — a JSON document that records the exact interactions — and publishes it to a shared broker.

On the provider side, a CI job pulls all contracts published by any consumer, starts the real provider server, and replays each interaction against it. If the real server doesn't match what the contract says — a missing field, a changed status code, a removed endpoint — the verification fails and the pipeline is blocked. The consumer drove the contract; the provider verifies it. Breaking changes are caught at the provider's CI boundary, not at integration testing time.

The consumer-driven contract flow

Consumer test suite
  └─ runs against Pact mock provider
       └─ generates pacts/frontend-user-api.json  (contract file)


       Pact Broker (PactFlow or self-hosted)


       Provider CI job
         └─ pulls all consumer contracts
              └─ replays each interaction against the real provider
                   └─ PASS: field names, status codes, types all match
                      FAIL: field renamed, endpoint removed, status code changed

Note:Pact is the most widely adopted consumer-driven contract testing spec. The spec is independent of the implementation: @pact-foundation/pact for Node.js, pact-jvm for the JVM, and pact-python, pact-go, and others for additional runtimes. All implementations read and write the same contract JSON format and connect to the same Pact Broker. This guide covers the Node.js and JVM paths in detail.

// What you'll need

Before starting, have these in place:

  • A consumer codebase — a Node.js application if you're following Path A, or a JVM application (Java/Kotlin) if you're following Path B
  • A provider codebase — the API service the consumer calls; ideally startable in CI (Docker, or a local dev server command)
  • A Pact Broker — either PactFlow (hosted SaaS, free tier available at pactflow.io) or a self-hosted Pact Broker via Docker (covered in Section 6)
  • CI/CD pipeline access on both the consumer repository and the provider repository — contract testing requires changes to both CI configs, not just one
  • @pact-foundation/pact v16.x (Path A) or au.com.dius.pact.consumer:junit5 v4.6.x (Path B) — both covered below

Note:This is more tooling than basic API testing. The value is proportional to scale: for a team running one Node.js consumer against one Java provider across two independently deployed repositories, the full setup below is the right investment. For simpler situations, reconsider whether integration tests against a shared staging environment would suffice.

// Path A: @pact-foundation/pact with Node.js

Install and configure

The standard Node.js Pact library is @pact-foundation/pact — the official pact-foundation implementation that generates standard Pact JSON and integrates with Pact Broker. Install it in your consumer project:

Install pact-js

npm install --save-dev @pact-foundation/pact@16

Note:If you've read that Pactum has 'first-class Pact integration' in the API testing libraries comparison — that refers to PactumJS's own bi-directional contract model (pactumjs-flow-server), which is a separate ecosystem from standard Pact. It does not generate standard Pact JSON files or connect to a Pact Broker. For standard consumer-driven contract testing that works with Pact Broker and across runtimes, use @pact-foundation/pact alongside your existing test setup.

Writing the consumer test

Create a Pact instance once per test suite — it represents the contract between this consumer and one provider. Use addInteraction() to describe each HTTP exchange the consumer depends on. The executeTest callback receives a mock server URL; point your consumer code at it during the test.

consumer/user-api.pact.test.ts

import { Pact, SpecificationVersion, Matchers } from '@pact-foundation/pact';
import path from 'path';

const { like, string } = Matchers;

// One Pact instance per consumer–provider pair
const provider = new Pact({
  dir: path.resolve(process.cwd(), 'pacts'),
  consumer: 'frontend',
  provider: 'user-api',
  spec: SpecificationVersion.SPECIFICATION_VERSION_V4,
});

describe('User API — consumer contract', () => {
  it('GET /users/1 returns a user', async () => {
    await provider
      .addInteraction()
      .given('user 1 exists')                   // provider state
      .uponReceiving('a request for user 1')    // interaction description
      .withRequest('GET', '/users/1', (builder) => {
        builder.headers({ Accept: 'application/json' });
      })
      .willRespondWith(200, (builder) => {
        builder.jsonBody({
          id: like(1),           // type match — any number is valid
          name: string('Alice'), // type match — any string is valid
        });
      })
      .executeTest(async (mockserver) => {
        // Call your real consumer code pointed at the mock server
        const res = await fetch(`${mockserver.url}/users/1`, {
          headers: { Accept: 'application/json' },
        });
        const body = await res.json();
        expect(res.status).toBe(200);
        expect(typeof body.id).toBe('number');
        expect(typeof body.name).toBe('string');
      });
  });
});

Use type matchers, not exact values

The like() and string() matchers from Pact tell the provider 'return something of this type' rather than 'return this exact value.' This is a critical distinction: if you match exact values in consumer contracts, your provider tests fail whenever seed data changes. Match the shape and type of the response; let the provider's own tests cover exact values.

The contract file is auto-generated to the pacts/ directory when the test passes. Each run overwrites the file — the contract always reflects the current test suite. Add pacts/ to .gitignore and publish the generated files to the broker from CI rather than committing them.

Publish contracts to broker (run in CI after tests)

npx @pact-foundation/pact-cli publish pacts \
  --consumer-app-version=$GITHUB_SHA \
  --broker-base-url=$PACT_BROKER_URL \
  --broker-token=$PACT_BROKER_TOKEN

Provider verification (Node.js side)

On the provider side, install @pact-foundation/pact again (or its provider-specific package) and write a verification test that starts your server and lets Pact replay every consumer contract against it.

provider/pact.verify.test.ts

import { Verifier } from '@pact-foundation/pact';

describe('User API — provider verification', () => {
  it('validates consumer contracts from broker', async () => {
    const verifier = new Verifier({
      provider: 'user-api',
      providerBaseUrl: 'http://localhost:3001', // your running server
      pactBrokerUrl: process.env.PACT_BROKER_URL!,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: true,
      providerVersion: process.env.GITHUB_SHA,
    });
    await verifier.verifyProvider();
  });
});

Note:The provider test must run against a live provider server. Start your server before the test (in beforeAll or via a Jest globalSetup), or point providerBaseUrl at an already-running instance. Provider states (the 'given' clauses from consumer tests) need state handlers that seed the database or mock the right conditions — see docs.pact.io/provider for the stateHandlers API.

// Path B: pact-jvm with REST Assured

Dependencies

REST Assured can serve as the HTTP client inside a pact-jvm consumer test. This is the well-documented JVM path: pact-jvm supplies the mock provider lifecycle and contract generation; REST Assured supplies the fluent HTTP calls. Add both to your pom.xml or build.gradle:

pom.xml (consumer and provider dependencies)

<!-- Consumer: pact-jvm JUnit 5 extension -->
<dependency>
  <groupId>au.com.dius.pact.consumer</groupId>
  <artifactId>junit5</artifactId>
  <version>4.6.14</version>
  <scope>test</scope>
</dependency>

<!-- Provider: pact-jvm JUnit 5 provider verification -->
<dependency>
  <groupId>au.com.dius.pact.provider</groupId>
  <artifactId>junit5</artifactId>
  <version>4.6.14</version>
  <scope>test</scope>
</dependency>

<!-- REST Assured (if not already present) -->
<dependency>
  <groupId>io.rest-assured</groupId>
  <artifactId>rest-assured</artifactId>
  <version>6.0.0</version>
  <scope>test</scope>
</dependency>

Note:Check Maven Central for the latest stable pact-jvm release — the 4.7.x line was available as a beta at the time of writing; 4.6.14 is the stable release referenced in this guide. Verify at central.sonatype.com/search?q=au.com.dius.pact.

Writing the consumer test (Java)

The JUnit 5 pact-jvm pattern uses @PactConsumerTest to manage the mock provider lifecycle, @Pact to define the interaction, and @PactTestFor to bind the test to a specific provider. The MockServer parameter is injected automatically — point REST Assured at its URL.

UserApiConsumerTest.java

import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.LambdaDsl;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTest;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

@PactConsumerTest
@PactTestFor(providerName = "user-api")
class UserApiConsumerTest {

  @Pact(consumer = "frontend")
  public RequestResponsePact getUserPact(PactDslWithProvider builder) {
    return builder
      .given("user 1 exists")
      .uponReceiving("GET /users/1")
        .method("GET")
        .path("/users/1")
        .headers("Accept", "application/json")
      .willRespondWith()
        .status(200)
        .body(LambdaDsl.newJsonBody(o -> o
          .numberType("id", 1)       // type match: any number
          .stringType("name", "Alice") // type match: any string
        ).build())
      .toPact();
  }

  @Test
  void getUserReturnsUserDetails(MockServer mockServer) {
    given()
      .baseUri(mockServer.getUrl())
      .header("Accept", "application/json")
    .when()
      .get("/users/1")
    .then()
      .statusCode(200)
      .body("id", notNullValue())
      .body("name", not(emptyOrNullString()));
  }
}

Provider verification (JVM side)

Provider verification pulls contracts from the broker and replays them against a running instance of the real provider. The @PactBroker annotation configures the broker connection; the @TestTemplate + PactVerificationInvocationContextProvider combination generates one test per consumer interaction.

UserApiProviderTest.java

import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import au.com.dius.pact.provider.junitsupport.loader.PactBrokerAuth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;

@Provider("user-api")
@PactBroker(
  url = "${PACT_BROKER_URL}",
  authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
class UserApiProviderTest {

  @BeforeEach
  void before(PactVerificationContext context) throws Exception {
    // Point at a running provider instance (started in @BeforeAll or a test container)
    context.setTarget(HttpTestTarget.fromUrl(new URL("http://localhost:8080")));
  }

  @TestTemplate
  @ExtendWith(PactVerificationInvocationContextProvider.class)
  void verifyPact(PactVerificationContext context) {
    context.verifyInteraction();
  }
}

Note:Publishing pact-jvm verification results back to the broker requires setting systemProp.pact.verifier.publishResults=true in your build config, along with pact.provider.version set to the current version (git SHA or build number). Without publishing results, the can-i-deploy check on the provider side cannot confirm whether the provider has verified its consumers.

// Setting up a Pact Broker

Option 1: PactFlow (hosted)

PactFlow at pactflow.io is the hosted Pact Broker service maintained by the pact-foundation team. The free Developer plan supports up to 5 integrations (consumer–provider pairs) and is sufficient for most small teams starting out. Larger teams can upgrade to paid tiers that add teams, webhooks, environment management, and SAML SSO.

Setup: sign up at pactflow.io, create a workspace, generate a read-write API token under Settings → API Tokens. Use the workspace URL as PACT_BROKER_URL and the token as PACT_BROKER_TOKEN in all publish and verify commands. No infrastructure to maintain — upgrades, backups, and availability are handled by PactFlow.

Option 2: self-hosted Pact Broker via Docker

The official pactfoundation/pact-broker Docker image runs on port 9292 by default and requires a PostgreSQL database. The setup below is sufficient for teams that need to keep contract data on their own infrastructure:

docker-compose.yml (self-hosted Pact Broker)

services:
  pact-broker:
    image: pactfoundation/pact-broker:latest
    restart: unless-stopped
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres:5432/pact
      PACT_BROKER_BASE_URL: https://pact-broker.example.com
      PACT_BROKER_BASIC_AUTH_USERNAME: pact
      PACT_BROKER_BASIC_AUTH_PASSWORD: CHANGEME
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:15
    restart: unless-stopped
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: CHANGEME
      POSTGRES_DB: pact
    volumes:
      - pact-postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "sh -c 'pg_isready -U pact -d pact'"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  pact-postgres-data:

Note:Self-hosting the broker means you own the database backup story — see the PostgreSQL backup pattern in the Git-based API collections guide if you need a reference. For most teams starting with contract testing, PactFlow's free tier is the path of least resistance. Self-hosting makes sense when data residency requirements prevent using any external SaaS for test artifacts.

// CI workflow patterns

Consumer CI: generate and publish

The consumer pipeline has two jobs: run the contract tests (generating pact files) and publish the contracts to the broker. Both run on every push. The publish step is what makes the contract visible to the provider.

consumer/.github/workflows/contracts.yml

name: Contract tests — consumer
on: [push, pull_request]

jobs:
  consumer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test          # generates pacts/ directory

      - name: Publish contracts to broker
        run: |
          npx @pact-foundation/pact-cli publish pacts \
            --consumer-app-version=$GITHUB_SHA \
            --broker-base-url=$PACT_BROKER_URL \
            --broker-token=$PACT_BROKER_TOKEN
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

Provider CI: verify and can-i-deploy

The provider pipeline starts the service, runs Pact verification against all consumer contracts from the broker, publishes the results, and then runs a can-i-deploy check before allowing merge to main. This check asks the broker: 'Is this version of the provider compatible with all consumer versions currently deployed to production?'

The can-i-deploy check is the whole point: without it, you've generated contracts and verified them, but nothing is actually blocking deployments of incompatible versions.

provider/.github/workflows/contracts.yml

name: Contract tests — provider
on: [push, pull_request]

jobs:
  provider:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci

      - name: Start provider server
        run: npm run start:test &    # adjust to your dev server command

      - name: Run provider verification
        run: npm run test:pact
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GITHUB_SHA: $GITHUB_SHA

      - name: Can I deploy?
        run: |
          npx @pact-foundation/pact-cli can-i-deploy \
            --pacticipant=user-api \
            --version=$GITHUB_SHA \
            --to-environment=production \
            --broker-base-url=$PACT_BROKER_URL \
            --broker-token=$PACT_BROKER_TOKEN
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

Note:For JVM providers using pact-jvm, the verification step is a Maven or Gradle test run (mvn test -Ppact-verify or similar profile). The can-i-deploy check uses the same @pact-foundation/pact-cli command regardless of language — one CLI, all runtimes.

// Common pitfalls

These are the failures that show up most often in teams adopting Pact for the first time:

  • Matching exact values instead of types — if your consumer contract says id must equal 42 and the provider returns id: 43 in its seed data, verification fails. Use like() (Node.js) or numberType()/stringType() (JVM) to match types rather than values. Reserve exact matching for field names and HTTP status codes.
  • Provider states not implemented — the 'given' clause in a consumer interaction ('user 1 exists') requires a corresponding state handler on the provider side that sets up the right database state or mock. Skipping state handlers means the provider's test data is whatever happens to be there, causing intermittent failures.
  • Pact JSON files committed to version control — the pacts/ directory should be in .gitignore. These files are generated by tests and published to the broker; committing them creates stale contracts that don't reflect the current test suite.
  • Can-i-deploy not blocking merges — can-i-deploy only matters if it's a required CI status check. Add it as a required check in your branch protection rules, or the check is advisory-only and changes will ship regardless.
  • Consumer and provider repos not both updated — a common mistake early on is adding Pact to the consumer and forgetting to add the provider verification job. The flow only works end-to-end when both sides publish results to the broker.
  • Over-specifying the contract — if every consumer test matches the entire response body with exact values, the contract becomes a snapshot test. Small provider changes that don't affect the consumer break the contract unnecessarily. Only specify the fields the consumer actually uses.

Note:If your can-i-deploy check returns 'failed' unexpectedly, check the broker UI to see which consumer version and which interaction caused the failure. The broker's visual diff shows exactly which field or status code didn't match. This is faster than reading raw JSON diff output from the CLI.

// What next

For broader API testing context beyond contracts — comparing REST Assured, Pactum, Supertest, Karate, and Tavern across 14 dimensions including assertion depth, CI integration, and mock server support — the API testing libraries comparison covers the full picture. Pactum's and REST Assured's contract testing rows in that matrix describe how each tool positions relative to the others.

If you're still deciding whether code-first API testing libraries are the right category for your team versus GUI clients, the 'Choosing between code-first libraries and GUI clients' guide covers six decision factors — team composition, CI strategy, and data sovereignty — and is the right starting point for that conversation.

For deeper Pact coverage beyond what this guide covers — message pacts, bidirectional contract testing, the Pact specification itself, and advanced broker configuration — docs.pact.io is the authoritative reference. The Pact workshop at github.com/pact-foundation/pact-workshop-js is the fastest hands-on path to an end-to-end working example.