How to Build a Contract Testing Suite with Pact for Microservices

typescript dev.to

How to Build a Contract Testing Suite with Pact for Microservices

How to Build a Contract Testing Suite with Pact for Microservices

Why Contract Testing Matters in Microservices

When you run multiple microservices, integration tests become a nightmare. You need to spin up every service, manage test data across databases, and deal with flaky network calls. Worse, when Service A breaks because Service B changed its API, you often don't know until production.

Contract testing solves this by verifying that services communicate correctly without running the entire system. Instead of testing the whole integration, you test the "contract" between producer and consumer services .

In this tutorial, you'll build a complete contract testing suite using Pact, the most popular contract testing library, with Node.js services.

What You'll Build

  • A consumer service (Order Service) that calls an API
  • A provider service (Product Service) that exposes the API
  • Pact contract tests that verify the API contract
  • A mock provider for consumer tests
  • Verification that the provider satisfies the contract

Prerequisites

  • Node.js 18+ installed
  • npm or yarn
  • Basic understanding of REST APIs
  • Familiarity with Jest for testing

Step 1: Set Up the Project Structure

Create a new directory and initialize two services:

mkdir contract-testing-tutorial
cd contract-testing-tutorial

### Create consumer service
mkdir consumer && cd consumer
npm init -y
npm install express @pact-foundation/pact jest ts-node typescript @types/node @types/express @types/jest

### Create provider service  
cd ..
mkdir provider && cd provider
npm init -y
npm install express @pact-foundation/pact jest ts-node typescript @types/node @types/express @types/jest
Enter fullscreen mode Exit fullscreen mode

Create a tsconfig.json in both directories:

{"compilerOptions":{"target":"ES2020","module":"commonjs","lib":["ES2020"],"outDir":"./dist","rootDir":"./src","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*"]}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build the Provider Service (Product Service)

Create provider/src/index.ts:

import express, { Request, Response } from 'express';

const app = express();
const PORT = 3001;

// Mock product database
const products = [
  { id: 1, name: 'Laptop', price: 999.99, inStock: true },
  { id: 2, name: 'Mouse', price: 29.99, inStock: true },
  { id: 3, name: 'Keyboard', price: 79.99, inStock: false },
];

// GET /products - returns array of products
app.get('/products', (req: Request, res: Response) => {
  res.json(products);
});

// GET /products/:id - returns single product
app.get('/products/:id', (req: Request, res: Response) => {
  const id = parseInt(req.params.id);
  const product = products.find(p => p.id === id);

  if (!product) {
    return res.status(404).json({ error: 'Product not found' });
  }

  res.json(product);
});

app.listen(PORT, () => {
  console.log(`Provider service running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Add to provider/package.json:

{"scripts":{"start":"ts-node src/index.ts","test":"jest"}}
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the Consumer Service (Order Service)

Create consumer/src/api.ts - this module makes HTTP calls to the provider:

import axios from 'axios';

const PROVIDER_URL = 'http://localhost:3001';

export interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

export interface ProductResponse {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

export async function getProducts(): Promise<Product[]> {
  const response = await axios.get<ProductResponse[]>(`${PROVIDER_URL}/products`);
  return response.data;
}

export async function getProductById(id: number): Promise<Product> {
  const response = await axios.get<ProductResponse>(`${PROVIDER_URL}/products/${id}`);
  return response.data;
}
Enter fullscreen mode Exit fullscreen mode

Install axios:

cd consumer
npm install axios @types/axios
Enter fullscreen mode Exit fullscreen mode

Create consumer/src/orderService.ts:

import { getProducts, getProductById, Product } from './api';

export class OrderService {
  async getAvailableProducts(): Promise<Product[]> {
    const products = await getProducts();
    return products.filter(p => p.inStock);
  }

  async getProductPrice(id: number): Promise<number> {
    const product = await getProductById(id);
    return product.price;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Write Contract Tests for the Consumer

This is the core of contract testing. Create consumer/src/pact/consumer.test.ts:

import { Pact } from '@pact-foundation/pact';
import axios from 'axios';

describe('Product Service Contract', () => {
  const pact = new Pact({
    consumer: 'OrderService',
    provider: 'ProductService',
    port: 1234,
    log: 'pact.log',
   (dir): string => {
      return 'pacts';
    },
    logLevel: 'info',
  });

  beforeAll(async () => {
    await pact.setup();
  });

  afterAll(async () => {
    await pact.finalize();
  });

  beforeEach(() => {
    pact.removeInteractions();
  });

  it('expects GET /products to return a list of products', async () => {
    const expectedProducts = [
      {
        id: 1,
        name: 'Laptop',
        price: 999.99,
        inStock: true,
      },
      {
        id: 2,
        name: 'Mouse',
        price: 29.99,
        inStock: true,
      },
    ];

    pact.addInteraction({
      state: 'products exist',
      uponReceiving: 'a request to get all products',
      withRequest: {
        method: 'GET',
        path: '/products',
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: expectedProducts,
      },
    });

    const response = await axios.get(expectedProducts);
    expect(response.data).toEqual(expectedProducts);
  });

  it('expects GET /products/:id to return a single product', async () => {
    const expectedProduct = {
      id: 1,
      name: 'Laptop',
      price: 999.99,
      inStock: true,
    };

    pact.addInteraction({
      state: 'product with id 1 exists',
      uponReceiving: 'a request to get product by id',
      withRequest: {
        method: 'GET',
        path: Pact.Matchers.regex({
          regex: '/products/1',
          example: '/products/1',
        }),
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: expectedProduct,
      },
    });

    const response = await axios.get('http://localhost:1234/products/1');
    expect(response.data).toEqual(expectedProduct);
  });
});
Enter fullscreen mode Exit fullscreen mode

Wait, there's an error in the test. Let me fix it:

Create the corrected consumer/src/pact/consumer.test.ts:

import { Pact } from '@pact-foundation/pact';
import axios from 'axios';

describe('Product Service Contract', () => {
  const pact = new Pact({
    consumer: 'OrderService',
    provider: 'ProductService',
    port: 1234,
    log: 'pact.log',
    dir: 'pacts',
    logLevel: 'info',
  });

  beforeAll(async () => {
    await pact.setup();
  });

  afterAll(async () => {
    await pact.finalize();
  });

  beforeEach(() => {
    pact.removeInteractions();
  });

  it('expects GET /products to return a list of products', async () => {
    const expectedProducts = [
      {
        id: 1,
        name: 'Laptop',
        price: 999.99,
        inStock: true,
      },
      {
        id: 2,
        name: 'Mouse',
        price: 29.99,
        inStock: true,
      },
    ];

    pact.addInteraction({
      state: 'products exist',
      uponReceiving: 'a request to get all products',
      withRequest: {
        method: 'GET',
        path: '/products',
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: expectedProducts,
      },
    });

    const response = await axios.get('http://localhost:1234/products');
    expect(response.data).toEqual(expectedProducts);
  });

  it('expects GET /products/:id to return a single product', async () => {
    const expectedProduct = {
      id: 1,
      name: 'Laptop',
      price: 999.99,
      inStock: true,
    };

    pact.addInteraction({
      state: 'product with id 1 exists',
      uponReceiving: 'a request to get product by id',
      withRequest: {
        method: 'GET',
        path: Pact.Matchers.regex({
          regex: '/products/\\d+',
          example: '/products/1',
        }),
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: expectedProduct,
      },
    });

    const response = await axios.get('http://localhost:1234/products/1');
    expect(response.data).toEqual(expectedProduct);
  });
});
Enter fullscreen mode Exit fullscreen mode

Add test script to consumer/package.json:

{"scripts":{"test":"jest","test:pact":"jest src/pact"}}
Enter fullscreen mode Exit fullscreen mode

Create consumer/jest.config.js:

module.exports = {
  preset: 'ts-node/register',
  testEnvironment: 'node',
  testMatch: ['**/*.test.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Write Provider Verification Tests

Now verify that the provider satisfies the contract. Create provider/src/pact/provider.test.ts:

import { PactV3, MockServer } from '@pact-foundation/pact';
import { createServer } from 'http';
import app from '../../index';

describe('ProductService Provider Verification', () => {
  let mockServer: MockServer;
  let appServer: any;

  beforeAll(async () => {
    // Start the mock Pact server
    mockServer = await new PactV3({
      consumer: 'OrderService',
      provider: 'ProductService',
      dir: 'pacts',
      log: 'pact.log',
    }).createMockServer();

    // Start the actual provider app on a different port
    appServer = app.listen(3002);
  });

  afterAll(async () => {
    await mockServer.cleanup();
    appServer.close();
  });

  it('verifies the provider satisfies the contract', async () => {
    const result = await new PactV3({
      consumer: 'OrderService',
      provider: 'ProductService',
      dir: 'pacts',
      log: 'pact.log',
    }).verifyProvider({
      pactUrl: 'pacts/OrderService-ProductService.json',
      providerBaseUrl: 'http://localhost:3002',
    });

    expect(result).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Actually, let me simplify this with a more practical approach. Create provider/src/pact/provider-verification.test.ts:

import { Pact } from '@pact-foundation/pact';
import request from 'supertest';
import app from '../index';

describe('ProductService Provider Verification', () => {
  const pact = new Pact({
    consumer: 'OrderService',
    provider: 'ProductService',
    port: 1235,
    log: 'pact.log',
    dir: 'pacts',
    logLevel: 'info',
  });

  beforeAll(async () => {
    await pact.setup();
  });

  afterAll(async () => {
    await pact.finalize();
  });

  it('verifies GET /products returns products', async () => {
    pact.addInteraction({
      state: 'products exist',
      uponReceiving: 'a request to get all products',
      withRequest: {
        method: 'GET',
        path: '/products',
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: [
          {
            id: 1,
            name: 'Laptop',
            price: 999.99,
            inStock: true,
          },
        ],
      },
    });

    const response = await request(app).get('/products');
    expect(response.status).toBe(200);
    expect(response.body).toBeInstanceOf(Array);
    expect(response.body).toHaveProperty('id');
    expect(response.body).toHaveProperty('name');
    expect(response.body).toHaveProperty('price');
    expect(response.body).toHaveProperty('inStock');
  });

  it('verifies GET /products/:id returns a product', async () => {
    pact.addInteraction({
      state: 'product with id 1 exists',
      uponReceiving: 'a request to get product by id',
      withRequest: {
        method: 'GET',
        path: Pact.Matchers.regex({
          regex: '/products/1',
          example: '/products/1',
        }),
      },
      willRespondWith: {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: {
          id: 1,
          name: 'Laptop',
          price: 999.99,
          inStock: true,
        },
      },
    });

    const response = await request(app).get('/products/1');
    expect(response.status).toBe(200);
    expect(response.body).toEqual({
      id: 1,
      name: 'Laptop',
      price: 999.99,
      inStock: true,
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Install supertest:

cd provider
npm install supertest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Step 6: Run the Contract Tests

Run Consumer Contract Tests

cd consumer
npm test  src/pact/consumer.test.ts
Enter fullscreen mode Exit fullscreen mode

This will:

  1. Start a mock Pact server on port 1234
  2. Run your tests against the mock server
  3. Generate a Pact contract file (pacts/OrderService-ProductService.json)

Run Provider Verification

cd provider
npm test  src/pact/provider-verification.test.ts
Enter fullscreen mode Exit fullscreen mode

This verifies the provider implementation matches the contract.

Step 7: Understanding the Generated Pact Contract

After running consumer tests, check the generated pact file:

{"consumer":{"name":"OrderService"},"provider":{"name":"ProductService"},"interactions":[{"description":"a request to get all products","request":{"method":"GET","path":"/products"},"response":{"status":200,"headers":{"Content-Type":"application/json; charset=utf-8"},"body":[{"id":1,"name":"Laptop","price":999.99,"inStock":true}]},"providerState":"products exist"}],"metadata":{"pactSpecification":{"version":"2.0.0"}}}
Enter fullscreen mode Exit fullscreen mode

This JSON file is the contract that defines exactly how the consumer and provider must communicate .

Step 8: Add Pact to Your CI/CD Pipeline

Create a GitHub Actions workflow .github/workflows/contract-tests.yml:

name: Contract Testing

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Install dependencies
        working-directory: ./consumer
        run: npm ci
      - name: Run consumer contract tests
        working-directory: ./consumer
        run: npm test  src/pact/consumer.test.ts
      - name: Upload Pact contract
        uses: actions/upload-artifact@v3
        with:
          name: pact-contract
          path: consumer/pacts/

  provider-verification:
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Download Pact contract
        uses: actions/download-artifact@v3
        with:
          name: pact-contract
          path: provider/pacts/
      - name: Install dependencies
        working-directory: ./provider
        run: npm ci
      - name: Run provider verification
        working-directory: ./provider
        run: npm test  src/pact/provider-verification.test.ts
Enter fullscreen mode Exit fullscreen mode

Step 9: Breaking the Contract (And Catching It)

Let's see contract testing in action when things break. Modify the provider to return a different field:

Provider change provider/src/index.ts:

// WRONG: Changed 'inStock' to 'available'
const products = [
  { id: 1, name: 'Laptop', price: 999.99, available: true },
  { id: 2, name: 'Mouse', price: 29.99, available: true },
];
Enter fullscreen mode Exit fullscreen mode

When you run the provider verification tests, they will fail with a clear error message:

Expected:{"id":1,"name":"Laptop","price":999.99,"inStock":true}Actual:{"id":1,"name":"Laptop","price":999.99,"available":true}Missingfield:inStock
Enter fullscreen mode Exit fullscreen mode

This catches the breaking change before it reaches production, unlike integration tests that might only fail sporadically .

Best Practices for Contract Testing

1. Keep Contracts Simple

Only test the essential parts of the API that consumers need. Don't contract-test every single field.

2. Use Provider States

Provider states describe the data setup needed for each interaction:

pact.addInteraction({
  state: 'user with id 123 exists',
  uponReceiving: 'a request to get user',
  // ...
});
Enter fullscreen mode Exit fullscreen mode

3. Version Your Contracts

Store pact files in version control and treat them as first-class artifacts.

4. Run Contracts in CI

Never deploy without verifying contracts. Make contract testing a gate in your CI pipeline.

5. Don't Replace Integration Tests Completely

Contract testing complements integration testing. Use contract tests for API contracts and integration tests for end-to-end scenarios.

Common Pitfalls to Avoid

| Pitfall | Solution |
|-||
| Testing implementation details | Only test the public API contract |
| Over-contracting | Contract only what consumers actually need |
| Ignoring provider states | Set up proper test data for each interaction |
| Not running in CI | Make contract tests mandatory in CI/CD |
| Contracting internal APIs | Focus on external-facing APIs between services |

When to Use Contract Testing

Use contract testing when:

  • You have multiple microservices
  • Services are owned by different teams
  • You need fast, reliable integration verification
  • You want to prevent breaking changes

Don't use contract testing when:

  • You have a monolithic application
  • Only one consumer exists (simple integration tests suffice)
  • You're testing UI behavior (use E2E tests instead)

Conclusion

Contract testing with Pact gives you:

  • Fast feedback: Tests run in seconds, not minutes
  • Reliability: No flaky network or database issues
  • Breaking change detection: Catch API changes before production
  • Team autonomy: Teams can deploy independently without coordinating releases

The key insight is that contract testing shifts integration testing left in your development process. Instead of discovering integration issues in staging or production, you catch them when writing code .

Start by contracting your most critical service interactions, then expand gradually. Within weeks, you'll have confidence that your microservices work together without running expensive integration test suites.

Resources

What's your experience with contract testing? Have you tried Pact or another tool? Share your thoughts in the comments below.

https://docs.pact.io/
https://martinfowler.com/articles/consumerDrivenContracts.html
https://pact.io/tutorial

-

Rizwan Saleem | https://rizwansaleem.co

Sources

Source: dev.to

arrow_back Back to Tutorials