TutorialsLast Updated Jul 22, 202610 min read

API contract testing with Joi

Waweru Mwaura

Software Engineer

When you sign a contract, you expect both parties to hold their end of the bargain. The same can be true for testing applications. Contract testing verifies that services can communicate with each other and that the data shared between the services is consistent with a specified set of rules. In this post, I will guide you through using Joi as a library to create API contracts for services consuming an API.

In this post, I will demonstrate writing contract tests for an open source API endpoint. The endpoint you will be testing returns the current Bitcoin exchange rates against many fiat and cryptocurrencies. You will then use a NodeJS app to test the contracts of the responses returned by the Coinbase API.

Prerequisites

To follow along with this tutorial, you will need:

  1. Basic knowledge of JavaScript.
  2. Basic knowledge of writing tests.
  3. Node.js installed on your system (version 22 LTS or newer).
  4. A CircleCI account.
  5. A GitHub account.

Our tutorials are platform-agnostic, but use CircleCI as an example. If you don’t have a CircleCI account, sign up for a free one here.

Cloning the demo project

To begin the exercise, you will need to clone the demo project. The project is a Node application that is built on top of Express.js. The Coinbuzz application itself has not yet been developed. For this tutorial, you will focus on the contract tests that will ensure the stability of the application as if it will be developed.

The project will use the Coinbase exchange-rates API. For this tutorial, you will use the endpoint https://api.coinbase.com/v2/exchange-rates?currency=BTC. This endpoint returns the current exchange rate of Bitcoin against a long list of fiat currencies (including USD and CNY) and cryptocurrencies. No API key is required.

Clone the project by running this command:

git clone https://github.com/CIRCLECI-GWP/coin-app-contract-testing.git

Install the dependencies from the project folder:

cd coin-app-contract-testing;

npm install

There is no need to run the application; you will be running only the tests in this project.

Why use contract testing

Before you get started, here is some background about contract testing. This kind of testing provides confidence that different services work when they are required to. Imagine that an organization has multiple payment services that use an Authentication API. The API logs in users into an application with a username and a password. It then assigns them an access token when the log-in operation is successful. Other services like Loans and Repayments require the Authentication API service once users are logged in.

Microservices dependencies

If the Authentication service changes the way it works and requires email instead of username, the Loans and Repayments services will fail. With contract testing, both the Loans and Repayments services can keep track of the Authentication API by having an expected set of behaviors with the requests made from the service. The services have access to information about when, how, and where failures are happening. There is also information about whether the failures have been caused by an external dependency, which in this case is Authentication.

Setting up the contract test environment

Contract tests are designed to monitor the state of an application and notify testers when there is an unexpected result. Contract tests are most effective when they are used by a tool that relies on the stability of other services. Two examples:

  • A front-end application that relies on the stability of a backend API (like in this project).
  • A microservices environment or an API that relies on another API to process information.

Testing the Coinbase exchange-rates endpoint will help you understand the price of Bitcoin in many currencies at the moment of the request. To learn more, review app.js in the cloned application.

Before you can begin testing the API, you need an understanding of its structure. That knowledge will help you write your contracts. First, make a GET request to this URL with curl (or paste it into a browser):

curl https://api.coinbase.com/v2/exchange-rates?currency=BTC

The response is a single data object containing the base currency ("BTC") and a rates object whose keys are currency codes and whose values are the price of one Bitcoin in that currency, expressed as a decimal string. A trimmed example:

{
  "data": {
    "currency": "BTC",
    "rates": {
      "USD": "78152.96",
      "CNY": "533671.42",
      "EUR": "66671.49"
    }
  }
}

The full response contains hundreds of currency entries. The next section breaks this down into a Joi contract.

Creating the Joi contracts

Joi is a tool that makes it possible to analyze objects and break them into chunks that can be validated. Joi views this response as a single object with a data key, whose value is itself an object with currency and rates. Each value is of a particular data type. By breaking down the response, Joi can analyze the response and create assertions of either success or failure based on the defined schema contracts.

To install the joi package for your project, open a terminal and run this command:

npm install joi

You will start by validating the inner rates object on its own, then nest it inside the contract for the full response. The next code block defines a ratesObject schema that requires both USD and CNY to be present and to be strings, while letting any other currency code pass through:

const ratesObject = Joi.object({
  USD: Joi.string().required(),
  CNY: Joi.string().required(),
})
  .unknown(true)
  .required();

In this code block, you are taking apart the rates object and telling Joi that you are expecting it to contain at least the keys USD and CNY, both of them required string values. The .unknown(true) modifier tells Joi to permit other rate keys (EUR, GBP, and the rest) without rejecting them. Without that modifier, the contract would reject every additional currency the API returns.

Partial API response and JOI contract

The ratesObject plugs into the larger ExchangeRatesContract so the schema covers the response top to bottom. The full contract integrates ratesObject inside data, alongside the currency field:

const ExchangeRatesContract = Joi.object({
  data: Joi.object({
    currency: Joi.string().required(),
    rates: ratesObject,
  }).required(),
}).required();

This section of the tutorial has covered creating Joi contracts and defining the properties of responses that need to be verified by the contract schema. Keep in mind that to “tighten” schemas, you should define whether the contract schema values are required or optional, and whether the parent object accepts unknown keys. This creates a boundary on whether to throw an error on failure or to ignore it.

Handling Joi contract errors

When writing contracts, it is important to handle the errors that may arise from issues with the responses. You will need to know whether the responses are consistent every time you execute your tests, and when there is a contract schema failure. Understanding potential contract failures will help you learn how to tweak your applications to handle the failures.

To handle the errors, create a directory and call it lib. Add two files to the directory:

  1. One for the request helper that helps make the API requests using axios.
  2. Another file for the schemaValidation() method to validate responses against the defined schemas.

Side by side lib files

The schema validation file verifies that the response provided and the contract are consistent. If they are not, it results in an error. The next code block shows how a method will verify the received response against the defined Joi contract schema.

async function schemaValidation(response, schema) {
  if (!response || !schema) {
    throw new Error("An API response and contract are required");
  }
  return schema.validateAsync(response, { abortEarly: false });
}

module.exports = {
  schemaValidation,
};

The schema.validateAsync() Joi method is responsible for validating the responses against the created schema. In this case, you already have a schema created in the file contracts/exchange-rates-contract.js. In the next section, you will verify that this method works by writing a test and passing in both the schema and the received API response from the Coinbase API.

Writing tests and assertions

You have successfully written your contracts and a method to validate them against API responses while checking for inconsistencies. Your next step is to write a test that uses this method. To test the API using your schema, you will need to install Jest, a JavaScript testing framework, and Axios, a JavaScript request-making framework.

npm install --save-dev jest
npm install axios

You will use Jest to run your tests, and axios to make the API requests to the Coinbase endpoint. To set this up, add the test command to run the tests in the package.json file:

"scripts": {
    "test": "jest"
  },

Adding the command in the scripts section enables Jest to scan your project for any file that has either a .spec or .test extension. When one is discovered, Jest treats them as test files and runs them.

Now, create a test inside the contract-tests directory. This test calls the schema validation method after making an API request.

const { getData } = require("../lib/request-helper");
const { schemaValidation } = require("../lib/validateContractSchema");
const { ExchangeRatesContract } = require("../contracts/exchange-rates-contract");

describe("Coinbase exchange-rates contracts", () => {
  test("BTC exchange-rates contract schema check", async () => {
    const response = await getData({
      url: "https://api.coinbase.com/v2/exchange-rates?currency=BTC",
    });
    return schemaValidation(response, ExchangeRatesContract);
  });
});

This call uses the getData() method to make the request. The getData() method uses Axios to make a call to the Coinbase API. It then uses the response to verify the API response against the schema validation method schemaValidation(). This already has your Joi schema definitions for the response received from your endpoint. Run your test and validate that it works:

npm test

Check your terminal for some good news.

Successful test run

Voila! Your test passes.

We are not done yet, though. You need to verify that the test also handles situations when the contracts are not correct. In the contract schema, you defined currency: Joi.string().required() inside data. The Coinbase API returns the currency as the string "BTC". Change the expectation to Joi.number() and find out if Joi will create errors. Edit the ExchangeRatesContract to match this:

const ExchangeRatesContract = Joi.object({
  data: Joi.object({
    currency: Joi.number().required(),
    rates: ratesObject,
  }).required(),
}).required();

After re-running your tests, you are indeed presented with an error.

Failed test run

Joi was able to capture the error. The contract now expects data.currency to be a number, but the API returns the string "BTC". Joi rejects the API response because it does not meet the criteria of the contract.

You have been able to show that your contract schema works. A modification of a response will result in a failure which you will be alerted about.

Change the field currency: Joi.number().required() in the ExchangeRatesContract back to Joi.string().required() to continue the tutorial.

Writing the CI pipeline configuration

In this section, you will automate the test by adding the pipeline configuration for CircleCI. Start by creating a folder named .circleci in the root directory. Inside the folder, create a config.yml file. Now add configuration details:

version: 2.1
jobs:
  build:
    working_directory: ~/repo
    docker:
      - image: cimg/node:24.15.0
    steps:
      - checkout
      - restore_cache:
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - run:
          name: install dependencies
          command: npm ci
      - save_cache:
          key: dependency-cache-{{ checksum "package-lock.json" }}
          paths:
            - ./node_modules
      - run:
          name: run contract tests
          command: npm test

In this configuration, CircleCI uses a Node Docker image pulled from the environment. If a cached node_modules exists for the current package-lock.json, it is restored. Dependencies are then installed with npm ci, which enforces the lockfile. The cache is saved for the next run, and the contract tests are executed.

Setting up a project on CircleCI

If you cloned the sample project repository, it is already initialized and set up in git. It can be helpful to understand how your project is integrated with CircleCI, so, to set up CircleCI, initialize a GitHub repository in your project by running the command:

git init

Next, create a .gitignore file in the root directory. Inside the file, add node_modules to prevent npm-generated modules from being added to your remote repository. Add a commit and then push your project to GitHub.

Log into CircleCI and click Projects. From there, click Create Project, then select the repository you want to set up — in this case, coin-app-contract-testing. CircleCI will detect the existing .circleci/config.yml and offer to start a pipeline against it.

Confirm the selection to complete the setup. The first pipeline runs automatically.

This will run successfully.

Successful CircleCI pipeline building green after Joi contract testing

Conclusion

In this tutorial, you have created a contract schema for an API response, a method to handle errors in the schema, and a test to verify that the contract schema works. I hope I have been able to demonstrate how easy it is to set up contract tests for APIs or frontend applications that depend on the availability of external services. Now that you can pinpoint a failing service to the accuracy of a changed response object, you can wave goodbye to undetected dependency flakiness.