Pact JS Tutorial: Contract Testing for Node.js Microservices

Pact JS Tutorial: Contract Testing for Node.js Microservices

This tutorial walks through implementing Pact contract testing in a Node.js microservice architecture. You'll write consumer tests that generate pact files, verify them on the provider side, and publish to a Pact Broker for CI/CD integration.

Setup

Install Pact for Node.js:

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

For modern projects using the V4 Pact specification (recommended):

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

The @pact-foundation/pact package includes everything: the consumer DSL, provider verification, and the Pact Broker client.

Scenario

Two microservices:

  • Order Service (consumer): Creates orders, needs to look up users
  • User Service (provider): Serves user data via REST API

The Order Service calls GET /users/:id to get user details when placing an order.

Consumer Test

The consumer test defines what the Order Service expects from the User Service. Pact runs a mock User Service during this test.

// order-service/tests/consumer/userService.test.js
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, regex } = MatchersV3;
const path = require('path');

const provider = new PactV3({
  consumer: 'order-service',
  provider: 'user-service',
  dir: path.resolve(process.cwd(), 'pacts'),
  port: 8080,
});

// The actual client code that calls the User Service
const UserClient = require('../../src/clients/userClient');

describe('User Service Client', () => {
  describe('GET /users/:id', () => {
    it('returns user data for a valid user ID', async () => {
      await provider
        .addInteraction({
          states: [{ description: 'user 123 exists' }],
          uponReceiving: 'a request for user 123',
          withRequest: {
            method: 'GET',
            path: '/users/123',
            headers: {
              Accept: 'application/json',
            },
          },
          willRespondWith: {
            status: 200,
            headers: {
              'Content-Type': regex({
                generate: 'application/json',
                matcher: 'application/json.*',
              }),
            },
            body: {
              id: like(123),
              name: like('Alice Smith'),
              email: like('alice@example.com'),
            },
          },
        })
        .executeTest(async (mockserver) => {
          const client = new UserClient(mockserver.url);
          const user = await client.getUser(123);

          expect(user.id).toBe(123);
          expect(user.name).toBeDefined();
          expect(user.email).toBeDefined();
        });
    });

    it('returns 404 for a user that does not exist', async () => {
      await provider
        .addInteraction({
          states: [{ description: 'user 999 does not exist' }],
          uponReceiving: 'a request for non-existent user 999',
          withRequest: {
            method: 'GET',
            path: '/users/999',
          },
          willRespondWith: {
            status: 404,
            body: {
              error: like('User not found'),
            },
          },
        })
        .executeTest(async (mockserver) => {
          const client = new UserClient(mockserver.url);
          await expect(client.getUser(999)).rejects.toThrow('User not found');
        });
    });
  });
});

The like() matcher means "a value of this type/shape" — the actual value in the generated pact file doesn't matter, only the structure. This prevents brittle tests tied to specific IDs.

The UserClient implementation being tested:

// order-service/src/clients/userClient.js
const axios = require('axios');

class UserClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl || process.env.USER_SERVICE_URL;
  }

  async getUser(userId) {
    const response = await axios.get(`${this.baseUrl}/users/${userId}`);
    if (response.status === 404) {
      throw new Error('User not found');
    }
    return response.data;
  }
}

module.exports = UserClient;

Run the consumer tests:

npx jest tests/consumer/

After a passing run, Pact generates a pact file at pacts/order-service-user-service.json:

{
  "consumer": { "name": "order-service" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user 123",
      "providerStates": [{ "name": "user 123 exists" }],
      "request": {
        "method": "GET",
        "path": "/users/123",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "body": {
          "id": 123,
          "name": "Alice Smith",
          "email": "alice@example.com"
        },
        "matchingRules": {
          "body": {
            "$.id": { "matchers": [{ "match": "type" }] },
            "$.name": { "matchers": [{ "match": "type" }] },
            "$.email": { "matchers": [{ "match": "type" }] }
          }
        }
      }
    }
  ]
}

Provider Verification

The provider verification runs against a real instance of the User Service and replays each interaction from the pact file:

// user-service/tests/provider/pactVerification.test.js
const { Verifier } = require('@pact-foundation/pact');
const path = require('path');
const app = require('../../src/app');

describe('Pact Verification', () => {
  let server;

  beforeAll((done) => {
    server = app.listen(3001, done);
  });

  afterAll((done) => {
    server.close(done);
  });

  it('validates the expectations of order-service', async () => {
    const verifier = new Verifier({
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:3001',

      // Load pacts from local file (for development)
      // In CI, load from Pact Broker instead
      pactUrls: [
        path.resolve(process.cwd(), '../order-service/pacts/order-service-user-service.json'),
      ],

      // Provider states: set up database state before each interaction
      stateHandlers: {
        'user 123 exists': async () => {
          // Insert test user into database
          await db.users.upsert({
            id: 123,
            name: 'Alice Smith',
            email: 'alice@example.com',
          });
        },
        'user 999 does not exist': async () => {
          // Ensure user 999 doesn't exist
          await db.users.delete({ id: 999 });
        },
      },

      logLevel: 'INFO',
    });

    return verifier.verifyProvider();
  });
});

Provider states (stateHandlers) set up the database state required for each interaction. The state name in the pact file ('user 123 exists') matches the handler key exactly.

Run provider verification:

npx jest tests/provider/

If the User Service's GET /users/:id endpoint returns a response matching the pact file (status 200, body with id, name, email fields), verification passes. If the provider changes the API (e.g., renames email to emailAddress), verification fails.

Publishing to Pact Broker

For CI/CD integration, publish pact files to a Pact Broker instead of sharing files locally.

Consumer: publish after tests pass:

const { Publisher } = require('@pact-foundation/pact');

const publisher = new Publisher({
  pactFilesOrDirs: ['./pacts'],
  pactBroker: process.env.PACT_BROKER_BASE_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  consumerVersion: process.env.GIT_SHA,
  tags: [process.env.GIT_BRANCH],
});

publisher.publishPacts();

Or use the Pact CLI directly:

pact-broker publish ./pacts \
  --broker-base-url $PACT_BROKER_BASE_URL \
  --broker-token <span class="hljs-variable">$PACT_BROKER_TOKEN \
  --consumer-app-version <span class="hljs-variable">$GIT_SHA \
  --tag <span class="hljs-variable">$GIT_BRANCH

Provider: fetch pacts from broker:

const verifier = new Verifier({
  provider: 'user-service',
  providerBaseUrl: 'http://localhost:3001',

  // Fetch pacts from broker instead of local files
  pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,

  // Verify all consumers that are tagged 'main' or 'production'
  consumerVersionSelectors: [
    { mainBranch: true },
    { deployedOrReleased: true },
  ],

  publishVerificationResult: true,
  providerVersion: process.env.GIT_SHA,
  providerVersionTags: [process.env.GIT_BRANCH],

  stateHandlers: {
    'user 123 exists': async () => { /* ... */ },
  },
});

CI/CD Integration

Full GitHub Actions workflow for consumer:

# .github/workflows/consumer-tests.yml
name: Consumer Tests

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
      PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
      GIT_SHA: ${{ github.sha }}
      GIT_BRANCH: ${{ github.ref_name }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:pact  # Runs consumer tests, generates pact files
      - run: npm run pact:publish  # Publishes to Pact Broker
      - name: Can I deploy?
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant order-service \
            --version $GIT_SHA \
            --to-environment production \
            --broker-base-url $PACT_BROKER_BASE_URL \
            --broker-token $PACT_BROKER_TOKEN

For the provider, run verification after the consumer publishes:

# Triggered by Pact Broker webhook when new pact is published
- run: npm run test:pact:verify
- run: |
    npx pact-broker can-i-deploy \
      --pacticipant user-service \
      --version $GIT_SHA \
      --to-environment production \
      --broker-base-url $PACT_BROKER_BASE_URL \
      --broker-token $PACT_BROKER_TOKEN

Common Mistakes

Not using matchers: Writing id: 123 instead of id: like(123) means the pact is tied to that specific value. The provider test will fail if it returns a different user ID.

Overly specific contracts: Specifying every field the provider returns, including ones the consumer doesn't use. Contract testing should only capture what the consumer actually depends on.

Not setting up provider states: If stateHandlers aren't configured, all interactions run against whatever data is already in the database. Results are non-deterministic.

Skipping can-i-deploy: Without this check, a provider that broke a consumer can still deploy. The check is what closes the safety loop.

Summary

  1. Consumer test: Mock provider → write interaction → run test → pact file generated
  2. Publish: Consumer pushes pact file to Pact Broker with version tag
  3. Provider verification: Provider fetches pact file, configures state handlers, verifies real implementation
  4. can-i-deploy: Both sides check broker before deploying — blocks unsafe deployments

Pact JS integrates with Jest, Mocha, and any Node.js test runner. The investment is real (provider states are the fiddly part), but the payoff — knowing whether your change will break a consumer before deployment — is substantial in microservice architectures.

Read more