Jest is a JavaScript-based testing framework that lets you test both front-end and back-end applications. Jest is great for validation because it comes bundled with tools that make writing tests more manageable. While Jest is most often used for simple API testing scenarios and assertions, it can also be used for testing complex data structures. In this tutorial, we will go through how to use Jest to test nested API responses and hopefully have fun in the process.

Prerequisites

To complete this tutorial, you will need:

  1. Node.js installed on your system
  2. A CircleCI account
  3. A GitHub account
  4. Postman or any other HTTP client to test the API
  5. Basic knowledge of JavaScript
  6. Basic understanding of APIs and unit testing

Cloning the repository

This tutorial will take you through the process of writing Jest tests for a dummy space API project. To access the entire project, clone the repository by running this command:

git clone --single-branch --branch base-project https://github.com/CIRCLECI-GWP/space-api
cd space-api

Creating our Space API

To start writing our cool Jest tests, we first need to have our API running. This is what we will write tests against. For this tutorial, we will use a simple API that will give us responses we can use to write our tests. I have chosen an API based on space travel, so gear up prospective space explorer!

The API for this tutorial has already been developed. Set it up with this command to install the application dependencies:

npm install

Start the application (API server):

npm start

After running these commands, we can start writing tests.

The API returns responses of the most crucial items required for space travel. It has these endpoints:

  • Available space destinations
  • Available flights and related information
  • A selection of flight seats and their configurations

Note: The focus of this tutorial is testing with Jest, not developing a space API repository, so there are no steps for that. If you are curious about all the endpoints available, or about the application architecture, go to the routing file of the application here.

Assuming you have cloned and set up the application on your machine, all endpoints are already present. Make a GET request with an API client like Postman to http://localhost:3000/space/flights, http://localhost:3000/space/destinations and http://localhost:3000/space/flights/seats.

The details have been hard coded onto the API endpoints.

space-api-response1

space-api-response2

space-api-response3

Now that you have experienced how our API works and simulates the responses, we can move on to testing.

Writing API tests with Jest

In the previous section, we set up an API to receive responses from the different API routes. In this section, we will dive into testing our API responses, understand how Jest can be used to test nested JavaScript objects and arrays, and learn how this will improve your testing skills as a developer or a test engineer. We will use JEST to test the different combinations of our API responses that range from a simple array to a complicated response of arrays containing objects and even an object containing arrays with more child arrays and objects.

Installing Jest

To begin testing with Jest, we need to add it to our currently existing space API. To install Jest as a development dependency, run this command.

npm install --save-dev jest

Note: Development dependencies are dependencies that are required for development purposes only. These dependencies should not be installed in a production environment.

After successfully running the installation, we need to make some changes to our package.json file to make sure that our space tests are run with the proper configuration. We will add two commands, one command for testing our API endpoints, and another command to request that Jest watch for changes in our files and re-run the tests. Add the Jest command on the package.json file:

"scripts": {
    "start": "node ./bin/www",
    "test": "jest"
},

You could add a Jest configuration file instead, if that works better for your project. For this tutorial, though, we will use Jest’s default configuration.

Setting up API testing in Jest

For our first test, create a tests folder in the root directory. Inside this folder, create a test file the main routes and add the .spec.js extension to it. In our case, that will be space.spec.js. Adding the .spec.js extension tells Jest that the file is a test.

Like space shuttle launches, we begin our test process with static fires to check that everything in our tests configuration is working correctly.

Inside the tests/space.spec.js file, implement this test using this code:

describe('Space test suite', () => {
    it('My Space Test', () => {
        expect(true).toEqual(true);
    });
});

Now when you run npm test on your terminal, you will get a successfully executing test. When the test runs and passes, the test results on the terminal should be green. You can further change the assertion from true to false and the execution on your terminal will result in red, signifying a failure.

Before we start the rocket launch sequence, we should add Jest’s watch command in our package.json. Adding this command means that when our application code changes the tests are re-run. This is possible when you have your application running in one terminal while your tests are in a different terminal. Add this command in the package.json file:

"scripts": {
    "start": "node ./bin/www",
    "test": "jest",
    "test:watch": "npm run test -- --watch"
},

When you execute your Node.js API while running the watch command, you can observe the re-run of our tests in real time when you change any code. This can come in handy to detect when your API breaks due to a change. Run the application in one terminal window, side-by-side with the tests, showing the watch option (on the right).

watching-jest

Setting up the project on CircleCI

Our next step is setting up a CircleCI pipeline to use when we run our tests. The pipeline helps you verify that everything is working as expected. To set up CircleCI, you first need to 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. Adding this to .gitignore keeps npm generated modules from being added to your remote repository. Now, add a commit and then push your project to GitHub.

Log into CircleCI and navigate to the Projects dashboard by clicking Projects on the side menu. There will be a list of all the GitHub repositories associated with your GitHub username or your organization.

adding-a-project

Click Set Up Project the tutorial project (it is the same name as the GitHub repository). Click Let’s Go.

default-setting

Click Skip this step.

start-building-page

Click Use Existing Config.

start-building-page

On the prompt, click Start Building. We expect our pipeline to fail, because we still need to add our customized config.yml configuration file to GitHub for the project to build properly.

start-building-prompt

Writing the CI pipeline

Once we have set up the pipeline, it is time to add CircleCI to our local project. Start by creating a .circleci folder in the root directory. Inside that folder, create a config.yml file. Now add details to the config.yml file by configuring it as shown in this code block:

version: 2.1
jobs:
  build:
    working_directory: ~/repo
    docker:
      - image: cimg/node:10.16.3
    steps:
      - checkout
      - run:
          name: update npm
          command: "npm install -g npm@5"
      - restore_cache:
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - run:
          name: install dependencies
          command: npm install
      - save_cache:
          key: dependency-cache-{{ checksum "package-lock.json" }}
          paths:
            - ./node_modules
      - run:
          name: run space test
          command: npm test
      - store_artifacts:
          path: ~/repo/space

In this configuration, CircleCI uses a node Docker image, pulled from the environment, then updates the npm package manager. The next stage is to restore the cache if it exists. The application dependencies are updated only when a change has been detected with save-cache. Finally, we will run our space tests and store the cached items in the artifacts’ space directory.

After writing this configuration, commit your changes to git and push your changes to GitHub. CircleCI should automatically start the building process and should pass, running only our not so meaningful test.

Testing our Space API

To make API requests in our Jest tests, we require a module that will query our endpoints and return the responses to our tests. That module is SuperTest, and you can install it using this command:

npm install  --save-dev supertest

Jest and SuperTest are set up, and a basic test has been written in the Jest testing framework. In this section, we will focus on testing the different API response objects provided by our endpoints. Start with the response provided by the endpoint http://localhost:3000/space/destinations. It is a simple array of objects representing the destinations space flights will travel to.

[
  "Mars",
  "Moon",
  "Earth",
  "Mercury",
  "Venus",
  "Jupiter"
]

An array is one of the most basic responses that you can receive from an API endpoint, yet Jest provides us with countless ways of asserting that the responses received meet our expectations.

Our first actual test will focus on this array of objects. Replace the contents of the space.spec.js file with this code snippet.

const request = require('supertest');
const app = require("../app");

describe('Space test suite', () => {
    it('tests /destinations endpoints', async() => {
        const response = await request(app).get("/space/destinations");
        expect(response.body).toEqual(["Mars", "Moon", "Earth", "Mercury", "Venus", "Jupiter"]);
        expect(response.body).toHaveLength(6);
        expect(response.statusCode).toBe(200);
        // Testing a single element in the array
        expect(response.body).toEqual(expect.arrayContaining(['Earth']));

    });

    // Insert other tests below this line

    // Insert other tests above this line
});

In this test, we get to interact with the API using SuperTest. The test first fetches the response from the /space/destinations endpoint, then uses the response to test it against a couple of assertions. The most notable assertion is the last one that uses the Jest array arrayContaining() method to verify that a single item can be found in an array. In this assertion, Jest maps through the array and knows which array elements are present in the array and which ones are not.

While the array above contained only five items, it is likely you will encounter more complicated scenarios where you will not be able to fully test the whole object without breaking it down.

In the next test, we will test the /space/flights/seats endpoint. This endpoint provides a response that is more complicated than the array one above, and we will again use Jest to test it.

Given the nature of our response, we will use a section of the received response from the endpoint and this will give us a template of tests that we expect to write for subsequent objects in the response.

{ "starship": [
    {
      "firstClass": {
        "heatedSeats": true,
        "chairOptions": [
          "leather",
          "wollen"
        ],
        "vaultAccess": true,
        "drinksServed": [
          "tea",
          "coffee",
          "space-special",
          "wine"
        ],
        "windowAccess": true,
        "privateCabin": "2",
        "VRAccess": "unlimited",
        "cost": "$20000",
        "seatHover": {
          "cryoMode": [
            "extreme",
            "ludacris",
            "plaid"
          ],
          "staticMode": [
            "ludacris",
            "plaid"
          ]
        }
      },
      "businessClass": { ... }
    }
  ],
  "blueOrigin": [
    {
      "firstClass": { ... },
      "businessClass": { ... }
    }
  ]

}

Note: You can always view the full response by calling the endpoint using a tool like Postman.

The focus for this test is writing an exhaustive test for the object while at the same time ensuring that the test is readable and simple. Given the complicated nature of the response, we will try to cover as much as possible while keeping the test readable.

...

    it('tests /space/flights/seats endpoint - starship', async () => {
        const response = await request(app).get("/space/flights/seats");
        expect(response.body.starship).toEqual(expect.arrayContaining([expect.any(Object)]));
        // Checking that the starship Object contains firstClass Object which then contains a set of objects
        expect(response.body.starship).toEqual(expect.arrayContaining(
            [expect.objectContaining({ firstClass: expect.any(Object) })]));

        expect(response.body.starship).toEqual(expect.arrayContaining([expect.objectContaining(
            { businessClass: expect.any(Object) })]));

        // Checking that under the bussinessClass Object we have the array drinks served
        expect(response.body.starship)
            .toEqual(expect.arrayContaining([expect.objectContaining({
                businessClass: expect.objectContaining({ drinksServed: expect.any(Array) })
            })]));

        // Checking that under the firstClass: Object we have the option ludacris in the seatHover Object
        expect(response.body.starship)
        .toEqual(expect.arrayContaining([expect.objectContaining({
            firstClass: expect.objectContaining({ seatHover: expect.objectContaining({
                cryoMode : expect.arrayContaining(['ludacris'])}) })
        })]));

        // Checking that under the firstClass: Object we have the option plaid in the seatHover Object
        expect(response.body.starship)
        .toEqual(expect.arrayContaining([expect.objectContaining({
            firstClass: expect.objectContaining({ seatHover: expect.objectContaining({
                staticMode : expect.arrayContaining(['plaid'])}) })
        })]));
    });

...

This test traverses the different levels of the provided object above using Jest matchers. It first tests the top-level objects, narrowing down to the objects and the arrays that have been nested deeply into the response. The test might look intimidating at first, but it shows that we can specifically test areas of the responses of an API without asserting the whole response object.

In the next section, we will learn how we can write custom error messages in Jest before our launch into space!

Using Jest custom matchers for error messaging

Jest matchers let you test data in different ways. For example, you can explicitly test a specific condition and even extend your own custom matcher if that condition is not covered by an existing one. Jest provides a way to declare custom matchers and have your own custom errors when running tests. Once we are in space, it would be great to have some level of control by overriding commands given by the command center. We do not want to crash into a meteorite or space debris if there is something we can do something to prevent it. To do this, we will use the response from the /space/flights API endpoint that we created above.

Jest provides the expect.extend(matchers) method so you can add custom expectations. In this tutorial, we want to validate that we can book only those flights that are active. In the flights endpoint we created, the object returns the different flights with their active status either set to true or false. Instead we can try writing a matcher for this.

To create a test that uses a custom matcher, we need to declare the matcher and define what parameters would lead to passing or failing. This matcher can be declared anywhere inside the Jest describe block. For readability, place it on top of the test file, immediately after the describe block. For this project, we want to check that the returned flights have a status of active.

...

    expect.extend({
        toBeActive(received) {
            const pass = received === true;
            if (pass) {
                return {  
                    message: () =>
                        `expected ${received} is an acceptable flight status`,
                    pass: true,
                };
            }
        }
    });

...

The custom matcher expects to receive the value of the state of the flight and return a custom message. There is a slight problem, though. When we run our tests, we get the wrong message even when the test does not meet the expectations of the passed flight status value. In other words, a false negative. To fix this, add an else statement to accommodate scenarios where our assertions will fail the criteria. The else statement will capture all the false negatives that could happen when running the test, and report them. Update the code like this:

...

    expect.extend({
        toBeActive(received) {
            const pass = received === true;
            if (pass) {
                return {
                    message: () =>
                        `expected ${received} to be an acceptable flight status`,
                    pass: true,
                };
            } else {
                return {
                    message: () =>
                        `expected ${received} to be an acceptable flight status of flight - only true is acceptable`,
                    pass: false,
                };
            }
        },
    });

...

The first custom message is launched when the test has a false negative. The second is launched when the test has failed by receiving a false value from the active state of the space flight.

Add this code block below the expect.extend code block we added previously.

...

    it('tests /space/flights endpoint - positive test', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[0].active).toBeActive();
    });

    it('tests /space/flights endpoint - false negative', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[0].active).not.toBeActive();
    });

    it('tests /space/flights endpoint - failing test', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[1].active).toBeActive();
    });

...

When you run these tests, some fail with this output:

> jest

 FAIL  tests/space.spec.js
  Space test suite
    ✓ tests /destinations endpoints (25 ms)
    ✓ tests /space/flights/seats endpoint - starship (4 ms)
    ✓ tests /space/flights endpoint - positive test (3 ms)
    ✕ tests /space/flights endpoint - false negative (2 ms)
    ✕ tests /space/flights endpoint - failing test (3 ms)
  ...

GET /space/destinations 200 2.851 ms - 51
GET /space/flights/seats 200 0.699 ms - 1073
GET /space/flights 200 0.610 ms - 1152
GET /space/flights 200 0.382 ms - 1152
GET /space/flights 200 0.554 ms - 1152
Test Suites: 1 failed, 1 total
Tests:       2 failed, 3 passed, 5 total
Snapshots:   0 total
Time:        0.512 s, estimated 1 s
Ran all test suites.

Note: The two failing tests have been written, as is, for demonstration purposes. To make the tests pass on the CI, skip them with the it.skip() function so they are not run by default.

...

    it('tests /space/flights endpoint - positive test', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[0].active).toBeActive();
    });

    it.skip('tests /space/flights endpoint - false negative', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[0].active).not.toBeActive();
    });

    it.skip('tests /space/flights endpoint - failing test', async () => {
        const response = await request(app).get("/space/flights");
        expect(response.body[1].active).toBeActive();
    });

...

And we have passing tests. Countercheck the final state of the tests/space.spec.js file on GitHub.

jest-matcher-passing-tests

Verifying pipeline success

When your tests are working and the CI pipeline is set up as described previously, add all the files to git and push them to your GitHub remote repository. The CircleCI pipeline should kick in and run the tests automatically. To observe the pipeline execution, go to the CircleCI dashboard and select the name of the tutorial project, which is similar to the GitHub repository name: space-api.

Click build from the CircleCI dashboard to review the status of every step as defined in your CircleCI configuration file.

build-status

Conclusion

In this tutorial, you have set up a space-api, installed Jest, and written tests against the API. You also learned how to use Jest matchers and even write your own custom matchers. Through the tutorial, we also covered how to integrate CircleCI into a project, push tests to GitHub, and create different build steps for the CI pipeline. Finally, we covered running our tests on the pipeline and verifying that all the steps of the pipeline are working as expected.

All systems are go for our launch into space. Happy T-minus-zero!


Waweru Mwaura is a software engineer and a lifelong-learner who specializes in quality engineering. An author at Packt, Waweru enjoys reading about engineering, finance, and technology. Learn more about him at: https://waweruh.github.io/.