Contract testing between services is often framed as a way to prevent integration issues, but its real power lies in enabling independent team velocity by clearly defining and enforcing service agreements.

Let’s see contract testing in action. Imagine two services: a ProductService that exposes product details and a CartService that consumes them.

ProductService (Provider)

First, we define the expected contract for the ProductService using Jest and a contract testing library like jest-pact.

// product.service.js
const products = {
  '123': { id: '123', name: 'Awesome Gadget', price: 99.99 }
};

async function getProduct(id) {
  // Simulate API call
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (products[id]) {
        resolve(products[id]);
      } else {
        reject(new Error('Product not found'));
      }
    }, 100);
  });
}

module.exports = { getProduct };

Now, the Jest test for the ProductService that will generate the contract:

// __tests__/product.contract.test.js
const { Pact } = require('@pact-foundation/pact');
const path = require('path');
const { getProduct } = require('../product.service'); // Assuming your service is here

const provider = new Pact({
  consumer: 'CartService',
  provider: 'ProductService',
  port: 8080, // Port the provider will run on for testing
  dir: path.resolve(process.cwd(), 'pacts'),
  log: path.resolve(process.cwd(), 'logs/pact.log'),
});

describe('ProductService Contract', () => {
  it('should return a product', async () => {
    await provider.setup(); // Ensure the pact server is running

    // Define the expected interaction
    await provider.given('a product exists').uponReceiving('a request for product 123').withRequest({
      method: 'GET',
      path: '/products/123',
    }).willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: { id: '123', name: 'Awesome Gadget', price: 99.99 },
    });

    // Now, call the actual service implementation with the mock server
    const product = await getProduct('123'); // This will hit the mock server

    expect(product.id).toBe('123');
    expect(product.name).toBe('Awesome Gadget');
    expect(product.price).toBe(99.99);

    await provider.finalize(); // Write the pact file
  });
});

When you run this test, jest-pact starts a mock server. Your getProduct function, when pointed to this mock server (which jest-pact handles internally during testing), will interact with it. The test asserts that the service behaves as expected for a given request. Crucially, provider.finalize() writes a pact.json file in the pacts directory. This file is the contract.

CartService (Consumer)

The CartService will consume this contract. It needs to verify that the ProductService it depends on adheres to the agreed-upon contract.

// cart.service.js
const axios = require('axios');

async function getProductFromProvider(id) {
  const response = await axios.get(`http://localhost:8080/products/${id}`); // Assuming ProductService is at this URL
  return response.data;
}

async function addProductToCart(productId) {
  const product = await getProductFromProvider(productId);
  // ... logic to add product to cart
  return { cartId: 'cart-abc', items: [{ productId, name: product.name, price: product.price }] };
}

module.exports = { addProductToCart, getProductFromProvider };

Now, the Jest test for the CartService that verifies the contract:

// __tests__/cart.contract.test.js
const { Pact } = require('@pact-foundation/pact');
const path = require('path');
const { addProductToCart } = require('../cart.service'); // Assuming your service is here

const providerBaseUrl = `http://localhost:8080`; // Where the provider is expected to be

const provider = new Pact({
  consumer: 'CartService',
  provider: 'ProductService',
  port: 8080, // Port for the mock server we'll spin up
  dir: path.resolve(process.cwd(), 'pacts'),
  log: path.resolve(process.cwd(), 'logs/pact.log'),
});

describe('CartService Consumer Contract', () => {
  beforeAll(async () => {
    await provider.setup(); // Start the mock server
    // Set up expectations for the mock server based on the pact file
    await provider.addInteraction({
      state: 'a product exists',
      uponReceiving: 'a request for product 123',
      withRequest: {
        method: 'GET',
        path: '/products/123',
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: { id: '123', name: 'Awesome Gadget', price: 99.99 },
      },
    });
  });

  afterAll(async () => {
    await provider.finalize(); // Verify interactions and tear down mock server
  });

  it('should add a product to the cart', async () => {
    // This test uses the mock server set up by Pact
    const cart = await addProductToCart('123');

    expect(cart.items.length).toBe(1);
    expect(cart.items[0].productId).toBe('123');
    expect(cart.items[0].name).toBe('Awesome Gadget');
  });
});

In the CartService test, jest-pact spins up a mock server that implements the contract defined in the pact.json file. When addProductToCart calls getProductFromProvider, it actually hits this mock server. The test then verifies that CartService correctly processes the response from the mock. The provider.finalize() call is critical: it checks if all the interactions defined in the pact file were actually made by the consumer. If CartService requested a different path, or expected a different response structure, this test would fail, indicating a broken contract.

The magic happens when you integrate this into your CI/CD pipeline. The ProductService publishes its pact.json file. The CartService pulls this pact.json file and runs its verification tests against a deployed version of the ProductService. If the tests pass, the contract is satisfied. If they fail, the deployment is blocked.

A common misconception is that contract testing replaces end-to-end (E2E) testing. It doesn’t. Contract tests verify that services adhere to their defined interfaces in isolation. E2E tests are still needed to validate the behavior of the integrated system as a whole. However, contract tests allow you to catch many integration issues much earlier and with significantly less flakiness than traditional E2E tests, enabling teams to move faster.

The most surprising aspect for many is how contract testing fundamentally shifts the responsibility for integration. Instead of the consumer chasing the provider for fixes, the provider is responsible for fulfilling the contract that the consumer has defined and verified. This is achieved by running the consumer’s verification tests against the provider’s actual running code in a CI environment. The provider’s build pipeline will pull the consumer’s pact files, spin up the provider service, and execute the consumer’s contract tests against it. If any test fails, it means the provider has broken the contract, and its build should fail, preventing the broken code from being deployed.

The next step in mastering service communication is exploring how to manage and share these pact files effectively, often using a Pact Broker.

Want structured learning?

Take the full Jest course →