Skip to content
GitHub Twitter

Contract Testing with Pact

Introduction

Contract testing is software testing technique used to ensure that separate components of a distributed system, such as microservices, interact with each other correctly. Pact is a popular open-source tool that facilitates consumer-driven contract testing, enabling developers to write tests that confirm the compatibility of service interactions without requiring the presence of the actual provider.

By using a mock provider, Pact creates contracts that represent the expected behavior of the services and can be shared with other teams. This is then verififed by a comsumer to ensure that the provider is meeting the contract.

Setting up the Provider

For simplicity we can put everything in the index.test.ts file. Firstly we setup the provider:

import { it, describe, expect } from "vitest";
import nock from "nock";
import fetch from "node-fetch";
import {
  PactV3,
  MatchersV3,
  SpecificationVersion,
} from "@pact-foundation/pact";
import path from "path";
const { like } = MatchersV3;

const provider = new PactV3({
  consumer: "FrontendWebsite",
  provider: "UserService",
  logLevel: "warn",
  dir: path.resolve(process.cwd(), "pacts"),
  spec: SpecificationVersion.SPECIFICATION_VERSION_V2,
});

The consumer and provider fields are used to identify the pact file. The dir option specifies the directory where the pact file will be written. The spec option specifies the version of the pact specification to use.

The Test

describe("GET /user/1", () => {
  it("returns an HTTP 200 and a single user", () => {
    // Arrange: Setup our expected interactions
    //
    // We use Pact to mock out the backend API
    provider
      .given("A user with id 1 exists")
      .uponReceiving("A request for a single user with id 1")
      .withRequest({
        method: "GET",
        path: "/users/1",
      })
      .willRespondWith({
        status: 200,
        headers: { "Content-Type": "application/json" },
        body: like({ id: "1", name: "James", age: 21 }),
      });
    return provider.executeTest(async (mockserver) => {
      // Act: test our API client behaves correctly
      //
      // We point out API to the mock service Pact created for us, instead of
      // the real one

      const res = await fetch(`${mockserver.url}/users/1`);
      const json = await res.json();

      // Assert: check the result
      expect(json).to.deep.eq({
        id: "1",
        name: "James",
        age: 21,
      });
    });
  });
});

We can break this down using the Arrange, Act, Assert pattern:

Arrange: Set up the expected interactions with the mock API provider.

The provider is set up with a condition that a user with ID 1 exists. Upon receiving a request for a single user with ID 1, the provider is configured to respond with an HTTP 200 status, a JSON content type, and a body containing the user's details.

Of note here is the use of the like matcher to specify that the response body should contain a user of similar shape to the one we expect and not necessarily this exact object. Also note that only the fields specified in the body will be checked, so any additional fields in the response will be ignored and the tests will still pass in the verification step.

Act: Test that the API client behaves correctly.

A request is made to the mock API provider's URL, simulating a call to the actual API endpoint to fetch the user with ID 1. The response is parsed as JSON.

Assert: Check the result.

The test asserts that the parsed JSON response matches the expected user details (ID 1, name "James", and age 21).

Running the Test and Generating the Pact File

Assuming you have your vitest script setup in your package.json, you can run the test with:

pnpm test

We now see that a pactfile has been generated at ./pacts/FrontendWebsite-UserService.json. The contents are not copied here in the interests of brevity, but you can see that it contains the expected interactions we defined in the test.

Verifying the Pact Contract

Now that we have a pact file, we can verify that the provider meets the contract:

app.listen(8081, async () => {
  console.log("User Service listening on http://localhost:8081");
});

describe("Pact Verification", () => {
  it("validates the expectations of Consuming Service", () => {
    return new Verifier({
      providerBaseUrl: "http://localhost:8081",
      pactUrls: [
        path.resolve(process.cwd(), "./pacts/FrontendWebsite-UserService.json"),
      ],
    })
      .verifyProvider()
      .then(() => {
        console.log("Pact Verification Complete!");
      });
  });
});

First we run the actual provider (API server), making sure that we stub out any external dependencies. Then we run the verification step, which will check that the provider meets the expectations defined in the pact file.

The repo with the code from this article can be found here.