DEV Community

Cover image for Streamlined Contract Testing in Node.js: A Simple and Achievable Approach
Ashley Davis for AppSignal

Posted on • Originally published at blog.appsignal.com

Streamlined Contract Testing in Node.js: A Simple and Achievable Approach

Do you want the benefits of contract testing with much less effort? Are you convinced of the benefits of contract testing but think it’s just too difficult to roll out across your organization?

You might worry that implementing Pact in your organization requires challenging changes to culture and process.

In this article, I’ll show you a drastically simplified approach to contract testing that a single developer can bring online. You'll get many of the benefits of contract testing for much less work. You'll build a platform that will help convince your team of how much contract testing can help prevent problems going out to production.

If you've ever tried to read the Pact documentation, you might think contract testing is a complicated team-based affair that requires new infrastructure. Let me show you that it doesn’t have to be this way.

Pre-requisites

The example code for this article is available on GitHub.

You can clone the code repository using Git or download the zip file and unpack it.

You’ll need Node.js installed to run the example code. The code repository contains three working examples that we’ll go through in turn. Follow along and try out each example for yourself.

Testing a REST API with a Simplified Contract

For our first example of simplified contract testing, let’s run some tests against an existing REST API. We’ll use the JSON Placeholder REST API.

JSON Placeholder is a fake REST API that includes endpoints for creating, updating, reading, and deleting “blog posts”. In Figure 1, you can see the JSON response for getting all blog posts. Load this URL in your web browser to see it for yourself:

Figure 1: The JSON response from JSON Placeholder getting all blog posts viewed in Chrome.
Figure 1: The JSON response from JSON Placeholder getting all blog posts, viewed in Chrome.

We'll use the Jest testing framework to run our contract tests. The Axios code library will make HTTP requests to the JSON Placeholder REST API, then we’ll check that the responses conform to a contract defined in a JSON schema. The setup for our first example is illustrated in Figure 2:

Figure 2: Using Jest to make HTTP requests and checking the responses against a JSON schema.
Figure 2: Using Jest to make HTTP requests and checking the responses against a JSON schema.

To try out the contract tests, you’ll need a local copy of the code repository and Node.js installed. Change into the rest-api subdirectory for the first example project:

cd simplified-contract-testing/rest-api
Enter fullscreen mode Exit fullscreen mode

Then install project dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Now run the tests:

npm test
Enter fullscreen mode Exit fullscreen mode

After a few moments, you should see the output from a handful of successful tests.

Now let’s see how the code works. Listing 1 shows a pared-down version of the YAML test spec that makes a HTTP request to the /post endpoint and checks the response against the GetPostsResponse schema. The code repo has an expanded version that contains tests for multiple HTTP endpoints.

schema:
  definitions:
    # Schema for a blog post:
    Post:
      title: Represents a blog post.
      type: object
      required:
        - userId
        - id
        - title
        - body
      properties:
        userId:
          type: number
        id:
          type: number
        title:
          type: string
        body:
          type: string
    # Schema for the REST API response:
    GetPostsResponse:
      title: GET /post response
      type: array
      items:
        # Reference to the Post schema.
        $ref: "#/schema/definitions/Post"
# List of tests specs.
specs:
  # Tests the REST API that gets the list of blog posts.
  - title: Gets all blog posts
    description: Gets all blog posts from the REST API.
    method: get
    url: /posts
    expected:
      status: 200
      headers:
        Content-Type: application/json; charset=utf-8
      body:
        # Reference to the schema for the REST API response.
        $ref: "#/schema/definitions/GetPostsResponse"
Enter fullscreen mode Exit fullscreen mode

Listing 1: A Test Spec That Makes an HTTP Request to the /posts Endpoint

How do we convert the test spec to a series of automated tests? For that, we'll use Jest’s test.each function. We can use a very cool technique to generate automated tests from our data.

In this case, we generate tests from our test spec loaded from the YAML file. You can see in Listing 2 that the code required to achieve this is minimal and not complicated. Listing 2 is slightly abbreviated, but not by much if you compare it to the complete code file.

const axios = require("axios");
const yaml = require("yaml");
const fs = require("fs");
const { resolveRefs } = require("./lib/resolver");

const { matchers } = require("jest-json-schema");
expect.extend(matchers);

// The REST API we are testing:
const baseURL = `https://jsonplaceholder.typicode.com`;

describe("Contract tests", () => {
  // Loads the test spec:
  const testSpec = resolveRefs(
    yaml.parse(fs.readFileSync(`${__dirname}/test-spec.yaml`, "utf-8"))
  );

  // Generates a test for each contract test in the spec:
  test.each(testSpec.specs)(`$title`, async (spec) => {
    // Makes the HTTP request:
    const response = await axios({
      method: spec.method,
      url: spec.url,
      baseURL,
      data: spec.body,
      validateStatus: () => true, // All status codes are ok.
    });

    // Matches headers:
    if (spec.expected.headers) {
      for ([headerName, expectedValue] of Object.entries(
        spec.expected.headers
      )) {
        const actualValue = response.headers[headerName.toLowerCase()];
        expect(actualValue).toEqual(expectedValue);
      }
    }

    // Matches response body against the expected schema:
    if (spec.expected.body) {
      expect(response.data).toMatchSchema(spec.expected.body);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Listing 2: Generating a Series of Jest Tests from Our Data

We have already seen how simple it can be to make data-driven contract tests against an existing REST API. Now let’s run our contract tests against a more realistic REST API backed by a database.

Mocking Your Database for Fast Contract Tests

To try out this second example for yourself, change into the mocked-mongodb subdirectory, install dependencies, and then run the tests:

cd simplified-contract-testing/mocked-mongodb
npm install
npm test
Enter fullscreen mode Exit fullscreen mode

The problem with testing against real infrastructure (like a real database) is that it makes our automated tests run very slowly. We can speed this up massively by mocking our database. By that, I mean replacing the real database with a fake version we can load up with pre-canned data fixtures to run our automated tests against. So while the real version of the service is configured to use a real database, the version we load for automated testing is configured to use the fake database instead. If you are interested, you can see the full code for the REST API in the code repo.

You can see how this looks in Figure 3. Now we are running a REST API locally, and we have it talking to a mock database where we can control the data fixtures on a test-by-test basis:

Figure 3: Mocking the database and controlling data fixtures on a test-by-test basis.
Figure 3: Mocking the database and controlling data fixtures on a test-by-test basis.

It turns out to be incredibly easy to mock whole modules when using Jest. Any file we place in the __mocks__ subdirectory can replace a real code module while automated tests run. So we place the file mongodb.js in the __mocks__ subdirectory, and Jest automatically replaces require(“mongodb”) with our mock version of the code. You can see our mock version of MongoDB in Listing 3. Note how it exports the function __setData__, which we can call from our tests to control the data fixture loaded for each test.

//
// A mock version of the MongoDB library.
//

let data = {}; // Data fixtures are plugged in here.

// --snip--

class MongoCollection {
  constructor(collectionName) {
    this.collectionName = collectionName;
  }

  async findOne({ _id }) {
    const collectionData = data[this.collectionName] || [];
    return collectionData.find((el) => el._id.__value === _id.__value);
  }

  find() {
    // A mock version of Mongodb's find function.
    return {
      async toArray() {
        // Returns the data fixture:
        return data[this.collectionName] || [];
      },
    };
  }

  async insertOne() {
    return {
      insertedId: new ObjectId("newly-inserted"),
    };
  }
}

class MongoDatabase {
  collection(collectionName) {
    return new MongoCollection(collectionName);
  }
}

class MongoClient {
  async connect() {}

  db() {
    return new MongoDatabase();
  }
}

// Allows automated tests to set data fixtures.
function __setData__(_data) {
  data = _data;
}

module.exports = {
  MongoClient,
  ObjectId,
  __setData__,
};
Enter fullscreen mode Exit fullscreen mode

Listing 3: The Mock Version of MongoDB

Now that we have a mock database and the ability to load data fixtures, we must give our test spec the ability to control which data fixture is loaded for each test. You can see a snippet of the updated test spec in Listing 3. Note the new fixture field that, in this case, specifies that this test should load the data fixture named many-posts.

- title: Gets all blog posts
    description: Gets all blog posts from the REST API.
    # Specifies the data fixture to load before running the test:
    fixture: many-posts
    method: get
    url: /posts
    expected:
        status: 200
        headers:
            Content-Type: application/json; charset=utf-8
        body:
            $ref: "#/schema/definitions/GetPostsResponse"
Enter fullscreen mode Exit fullscreen mode

Listing 4: A Test That Loads the Data Fixture many-posts

What we are missing now is the code that loads the data fixture for each test, which you can see in Listing 5 — or check out the full version in the code repo. We are now running a local REST API so you can see how we start the web server before each test. Also note the call to loadFixture at the start of each test, which loads the data
fixture requested by the test.

// --snip--

describe("Contract tests", () => {
  // Loads the test spec:
  const testSpec = resolveRefs(
    yaml.parse(fs.readFileSync(`${__dirname}/test-spec.yaml`, "utf-8"))
  );

  // --snip--

  beforeEach(async () => {
    // Starts the web server on a random port before each test:
    await startServer();
  });

  afterEach(async () => {
    await closeServer();
  });

  test.each(testSpec.specs)(`$title`, async (spec) => {
    if (spec.fixture) {
      // Loads a named database fixture into the mock MongoDB for each test:
      loadFixture(spec.fixture);
    } else {
      clearFixture();
    }

    // Makes the HTTP request:
    const response = await axios({
      method: spec.method,
      url: spec.url,
      baseURL,
      data: spec.body,
      validateStatus: () => true,
    });

    // Matches status:
    if (spec.expected.status) {
      expect(response.status).toEqual(spec.expected.status);
    }

    // Matches headers:
    if (spec.expected.headers) {
      for ([headerName, expectedValue] of Object.entries(
        spec.expected.headers
      )) {
        const actualValue = response.headers[headerName.toLowerCase()];
        expect(actualValue).toEqual(expectedValue);
      }
    }

    // Matches response body against the expected schema:
    if (spec.expected.body) {
      expect(response.data).toMatchSchema(spec.expected.body);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Listing 5: Starting the Web Server and Loading Data Fixtures Before Each Test

Running contract tests against a REST API is pretty useful, but we must often also work with services that communicate via asynchronous message queues. For this third and final example, we will extend our REST API so that it can send and receive messages through a RabbitMQ instance. Try out the tests for yourself:

cd simplified-contract-testing/mocked-rabbit
npm install
npm test
Enter fullscreen mode Exit fullscreen mode

Mocking Your Message Queue for Fast, Asynchronous Contract Tests

Please note that we are mocking RabbitMQ in this example, but in principle, this technique can work for any asynchronous messaging system (such as Kafka or SQS).

Let's mock the module amqplib (essentially RabbitMQ), so that we can:

  • Directly invoke asynchronous message handlers.
  • Retrieve asynchronous published messages.

You can see how this looks in Figure 4. Our Jest tests are now acting through our mock version of RabbitMQ to interact with our REST API:

Figure 4: Using a mock version of RabbitMQ to interact with our REST API during automated testing.
Figure 4: Using a mock version of RabbitMQ to interact with our REST API during automated testing.

Our mock version of AMQPlib, which we use to replace RabbitMQ, is similar to our mock version of MongoDB. We add the file amqplib.js to the __mocks__ subdirectory and Jest automatically replaces require(“amqplib”) with the mock version you see in Listing 6. Published messages are saved in the __published__ object, and the mock AMQPlib also tracks message handlers in the __consume__ object.

const crypto = require("crypto");

// Allows the contract tests to check messages that were published to the message queue:
const __published__ = {};

// Allows the contract tests to invoke message handlers:
const __consume__ = {};

const __queue_bindings__ = {};

const mockChannel = {
  async assertExchange() {},

  async assertQueue() {
    return {
      queue: crypto.randomBytes(5).toString("hex"),
    };
  },

  async bindQueue(queueName, exchangeName) {
    __queue_bindings__[exchangeName] = queueName;
  },

  // Publishes a message to a mock queue.
  async publish(exchange, routingKey, content) {
    __published__[exchange] = JSON.parse(content.toString());
  },

  // Binds an event handler to a message queue.
  async consume(queue, handler) {
    __consume__[queue] = handler;
  },

  ack() {},
};

const mockConnection = {
  async createChannel() {
    return mockChannel;
  },
};

async function connect() {
  return mockConnection;
}

module.exports = {
  connect,
  __published__,
  __consume__,
  __queue_bindings__,
};
Enter fullscreen mode Exit fullscreen mode

Listing 6: The Mock Version of AMQPlib Used to Replace RabbitMQ

With RabbitMQ mocked, our Jest tests can now invoke asynchronous message handlers directly in our code and then check any asynchronous responses that are subsequently published. Now that we can trigger HTTP requests or asynchronous messages from our tests, we need to update our test spec to specify the required type for each test. You can see an example in Listing 7. Note how the type field specifies rabbit, and the exchange field selects the RabbitMQ “exchange” we are using to trigger the asynchronous message handler we are trying to test.

At this point, we are not just checking “immediate responses” from HTTP requests, we now must also be able to check for asynchronous messages and compare them against the expected JSON schema. Note the asyncResponse section of Listing 7 and how it defines the schema for a message that is expected to be published on the payment-processed exchange.

- title: Processes a payment on new user
    description: Processes a payment on adding a new user.
    fixture: many-posts
    # The "trigger" that invokes the code to be tested:
    type: rabbit
    exchange: new-user
    body:
        userId: 1
        name: John Doe
    expected:
        # Expectations for the asynchronous response:
        asyncResponse:
            type: rabbit
            exchange: payment-processed
            body:
                $ref: "#/schema/definitions/UserPaymentProcessedMessage"
Enter fullscreen mode Exit fullscreen mode

Listing 7: A Test Triggers an Async Message Handler

We have extended our contract testing system to be even more flexible:

  • We can trigger HTTP requests or asynchronous message handlers.
  • We can match HTTP responses and asynchronous responses against a schema.

This means we can test the following combinations:

  • Trigger an HTTP request and check that its immediate response matches expectations.
  • Trigger an HTTP request and check that it results in publishing an asynchronous message that matches expectations.
  • Trigger an async message handler and check that it results in publishing an asynchronous message that matches expectations.

You can find examples of all these combinations in the expanded test spec in the code repo.

The code that generates our contract testing suite is getting more complex, as you can see in Listing 8. Still, it’s not bad considering the number of tests and the different testing combinations that we can achieve with this relatively small piece of code. See the full code that generates the contract tests in the code repo.

// --snip--

// The "http" trigger.
async function http(spec) {
  return await axios({
    method: spec.method,
    url: spec.url,
    baseURL,
    data: spec.body,
    validateStatus: () => true,
  });
}

// The "rabbit" trigger.
async function rabbit(spec) {
  const queue = amqplib.__queue_bindings__[spec.exchange];
  if (!queue) {
    throw new Error(`No queue is bound for exchange '${spec.exchange}'`);
  }

  // Uses the mock "amqplib" to invoke the message handler:
  const consumeHandler = amqplib.__consume__[queue];
  if (!consumeHandler) {
    throw new Error(
      `No consumer found for queue '${queue}' bound to exchange '${spec.exchange}'`
    );
  }

  consumeHandler({ content: Buffer.from(JSON.stringify(spec.body)) });
}

const triggers = {
  http,
  rabbit,
};

test.each(testSpec.specs)(`$title`, async (spec) => {
  // --snip--

  // Triggers the code to be tested.
  const trigger = triggers[spec.type];
  const immediateResponse = await trigger(spec);

  // Matches the immediate response against the expected schema.
  if (spec.expected.immediateResponse) {
    if (spec.expected.immediateResponse.status) {
      expect(immediateResponse.status).toEqual(
        spec.expected.immediateResponse.status
      );
    }

    if (spec.expected.immediateResponse.headers) {
      for ([headerName, expectedValue] of Object.entries(
        spec.expected.immediateResponse.headers
      )) {
        const actualValue = immediateResponse.headers[headerName.toLowerCase()];
        expect(actualValue).toEqual(expectedValue);
      }
    }

    if (spec.expected.immediateResponse.body) {
      expect(immediateResponse.data).toMatchSchema(
        spec.expected.immediateResponse.body
      );
    }
  }

  // Matches the async response against the expected schema.
  if (spec.expected.asyncResponse) {
    const expectedResponse = spec.expected.asyncResponse;
    if (!expectedResponse.exchange) {
      throw new Error(
        `An exchange is required for async responses on spec '${spec.title}'`
      );
    }
    const actualAsyncResponsePayload =
      amqplib.__published__[expectedResponse.exchange];
    if (!actualAsyncResponsePayload) {
      throw new Error(
        `Expected async response to be published on exchange '${expectedResponse.exchange}'.`
      );
    }

    if (expectedResponse.body) {
      expect(actualAsyncResponsePayload).toMatchSchema(expectedResponse.body);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Listing 8: Our Contract Testing Code Now Handles HTTP and Asynchronous Requests and Responses

Our adventure in contract testing isn’t complete until we fully automate our tests.

Adding Contract Tests to Your CI/CD Pipeline

We can automate our tests in any CI/CD provider, as long as we can clone our code, install Node.js, and run the following commands in our project:

npm ci
npm test
Enter fullscreen mode Exit fullscreen mode

Given that our external dependencies are mocked (MongoDB and RabbitMQ in these examples), that’s all that we need to get our simplified contract tests running in our CI/CD pipeline.

Listing 9 shows a CI pipeline for GitHub Actions. You can see the full, multi-project version in the code repo.

name: Automated contract tests

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

jobs:
  contract-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v3 # Installs Node.js.
        with:
          node-version: 20

      - run: npm ci # Installs project dependencies.
      - run: npm test # Runs the automated contract tests.
Enter fullscreen mode Exit fullscreen mode

Listing 9: The GitHub Actions Workflow Runs Simplified Contract Tests for a Project

Back in the beginning of this article, I showed how we can run contract tests against an existing REST API. In the same way, we can run our contract tests against any REST API or service that we can access.

Because we control the code making the HTTP request, we can augment the code to include authentication details for the production deployment of our REST API and then run the contract tests against it. If you are contract testing against a production service via asynchronous messaging, then you'll need access (e.g., via an SSH tunnel or Kubernetes port forwarding) to your message queue.

Obviously, we must be very careful if we run contract tests against a production system, especially when tests can make changes to that system. In the full version of my example, you may have noticed that one of the tests makes an HTTP POST request to create a blog post in the REST API. Tests like this are probably best run against a QA or staging deployment, but if you really do want to run them against production, you might want a test account to easily flush out after running tests.

Bringing It Together: How I Applied Contract Testing

In my team, we used the simplified approach to contract testing covered in this post without making the team adopt any arduous processes. We integrated it into our usual automated testing process (in this example, running npm test in our CI pipeline), and we didn’t need any new infrastructure (like a contract broker) to bring all this together.

Mocking, while not specifically related to contract testing, proved essential in making it convenient and fast to run these tests. Contract testing is more on the level of integration testing in terms of how much code it can cover for the least amount of effort.

But because we mock certain external services (like the database and message queue), our contract tests can be much closer to unit tests in terms of performance. This makes it easy and fast to run our contract tests locally for frequent feedback while we make code changes to a REST API or microservice.

Wrapping Up

In this article, we've shown you that contract testing in Node.js can be as simple as defining a JSON schema and then using your favorite testing framework to check responses from REST APIs and asynchronous messaging.

Using this simplified approach to contract testing, you can get started very quickly and gain practical results that will help convince your team that contract testing is worthwhile.

Happy testing!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Top comments (0)