Contract Testing with Pact: An API ‘Break-Proof’ Solution for Node.js Microservices

Development tutorial - IT technology blog
Development tutorial - IT technology blog

The Nightmare of Integration Tests Bloating as Systems Scale

Have you ever been in a situation where Service A’s unit tests pass, Service B’s pass, but as soon as you deploy to Staging, the system crashes? The most common culprit is the Producer changing the JSON response structure, causing the Consumer’s code to break, even though both passed their individual tests.

Typically, we write more End-to-End (E2E) tests for peace of mind. However, as the system reaches 20-30 services, spinning up an environment to run these tests can consume tens of gigabytes of RAM and hours of waiting. I once worked on a project where the integration test suite took 45 minutes, dragging the team’s release velocity down significantly.

Contract Testing was born to solve this problem. Among the tools available, Pact is the industry standard that helps you verify compatibility between services without needing to spin up the entire system for testing.

Contract Testing: A Binding Agreement Between Services

Think of Contract Testing as a business contract. The Consumer and the Producer sign terms regarding data. As long as both parties adhere to this contract, they don’t need to meet in person (Integration Test) for the work to flow smoothly.

Pact operates on the Consumer-Driven Contract Testing (CDCT) model. The Consumer proactively defines: “I need this API to return exactly this structure.” A JSON file (Pact file) is generated as evidence. Then, the Producer simply takes this file to verify if their code meets those expectations.

Hands-on: Implementing Pact in a Node.js Project

Suppose we have an Order Service (Consumer) that needs to fetch information from a Product Service (Producer).

1. Setting up the Consumer (Order Service)

First, install the library:

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

We will write a test to simulate the request and define the desired data structure from the /products/:id API.

// product.pact.test.js
const { PactV3 } = require('@pact-foundation/pact');
const path = require('path');

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'ProductService',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('GET /product/:id', () => {
  it('returns product info when ID exists', async () => {
    provider.addInteraction({
      states: [{ description: 'product with ID 10 exists' }],
      uponReceiving: 'a request for product 10',
      withRequest: {
        method: 'GET',
        path: '/products/10',
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: '10',
          name: 'Mechanical Keyboard',
          price: 1500000,
        },
      },
    });

    await provider.executeTest(async (mockService) => {
      // Call the actual function in your source code
      // const response = await getProduct(mockService.url, '10');
      // expect(response.data.name).toBe('Mechanical Keyboard');
    });
  });
});

After running the test, Pact will automatically export a JSON file in the /pacts directory. This is the “contract” to be sent to the Producer.

2. Verifying the Producer (Product Service)

On the Producer side, the task is simple: Ensure the API response matches what the Consumer requested in the JSON file.

// verify-pact.test.js
const { Verifier } = require('@pact-foundation/pact');

describe('Pact Verification', () => {
  it('must comply with the contract from OrderService', async () => {
    const opts = {
      provider: 'ProductService',
      providerBaseUrl: 'http://localhost:3000',
      pactUrls: [path.resolve(__dirname, '../../pacts/orderservice-productservice.json')],
    };

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

When executed, Pact automatically sends simulated requests to the Producer server and compares the returned response. If you accidentally change the name field to product_name, the test will fail immediately.

Real-world Experience to Avoid Getting Overwhelmed

Applying Pact to a real project can be difficult if you don’t master these 3 principles:

  • Don’t test business logic: Pact is only for checking the Data Structure (Schema). Don’t try to test if the product price calculates tax correctly. Leave that to Unit Tests.
  • Use a Pact Broker: Don’t copy JSON files manually. Use a Pact Broker to manage versions. This tool provides a highly valuable can-i-deploy feature, letting you know exactly if Service A is safe to go to Production.
  • Reduce Integration Test load: Once you have Pact, you can confidently cut down 60-70% of cumbersome E2E scenarios, making your CI/CD pipeline significantly faster.

Final Thoughts

Contract Testing isn’t a silver bullet, but it is a shield that protects Microservices systems from silly API breaking changes. If you’re tired of fixing bugs after every deployment, try incorporating Pact into your development workflow today. The peace of mind when seeing contracts successfully verified is a feeling well worth the investment.

Share: