Graduate Program KB

Tutorial: Using Supertest Library to Test Node HTTP API

In this tutorial, we will teach you how to use the Supertest library to test your Node HTTP API. Supertest is a popular and powerful library for testing HTTP APIs, allowing you to simulate API calls and test the behavior of your endpoints.

Prerequisites

Before we start, make sure you have the following installed on your machine:

  1. Node.js (v12.0.0 or higher)
  2. npm (Node Package Manager)

To check if Node.js and npm are installed, run the following commands in your terminal:

node -v
npm -v

If you don't have Node.js or npm installed, you can download them from the official website: https://nodejs.org/

Step 1: Setting Up the Project

First, create a new folder for your project and navigate to it in your terminal:

mkdir node-api-testing
cd node-api-testing

Next, initialize your project using npm:

npm init -y

This command will create a package.json file in your project folder.

Step 2: Installing Dependencies

For this tutorial, we'll use Express to create the HTTP API and Mocha as our test framework. Install these packages along with Supertest using the following command:

npm install express
npm install mocha chai supertest --save-dev

Step 3: Creating a Simple Express API

In your project folder, create a new file called app.js and add the following code:

const express = require('express');
const app = express();


app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

module.exports = app;

This code creates a simple Express server with one endpoint that responds with "Hello, World!" when accessed.

Step 4: Configuring Mocha

In your package.json file, add a new key called "scripts" with the following content:

"scripts": {
  "test": "mocha --exit --recursive test/*.test.js",
  "test-watch": "$npm_execpath run test --watch"
}

This command tells Mocha to run all test files in the test folder and exit when tests are done.

Step 5: Running the Tests

To run your tests, use the following command:

npm run test

If your tests pass, you'll see output similar to this:

  GET /
    ✓ should respond with "Hello, World!" (46ms)

  1 passing (51ms)

Step 6: Writing Tests Using Supertest

Create a new folder named test in your project directory, and inside it, create a file named app.test.js. Add the following code to app.test.js:

const request = require('supertest');
const app = require('../app');
const {expect} = require('chai')

describe('GET /', () => {
  it('should respond with "Hello, World!"', async () => {
    const res = await request(app).get('/');
    expect(res.status).to.equal(200);
    expect(res.text).to.equal('Hello, World!');
  });
});

This test should fail since we haven't implemented a route handler for /. In order to make the test pass we'll add the following content to app.js.

app.get('/', (req, res) => {
  res.status(200).send('Hello, World!');
});

In this code, we're importing the app instance from app.js and using Supertest to simulate an HTTP GET request to the root endpoint. We then check if the response status is 200 and if the response text is "Hello, World!". Supertest can be used to test other HTTP methods like POST, PUT, DELETE, etc. For example:

Testing different methods

Post

Add the following test case to app.test.js

it('should create a new resource', async () => {
  const res = await request(app).post('/resource').send({ name: 'New resource' });
  expect(res.status).to.equal(201);
  expect(res.body).to.have.property('id');
  expect(res.body.name).to.equal('New resource');
});

This test will fail because we don't have a handler for the POST method. In order to make the test pass we'll add the following content to app.js.

app.post("/resource", (req, res) => {
  res.status(201).send({ id: "1", name: req.body.name });
});

Put

Add the following test case to app.test.js

it("should handle put request", async () => {
  const res = await request(server.app)
    .put("/resource/55")
    .send({ name: "person" });
    expect(res.status).to.equal(200);
    expect(res.body).to.deep.equal({ id: "55", name: "person" });
});

This test will fail because we don't have a handler for the PUT method. In order to make the test pass we'll add the following content to app.js.

this.app.put("/resource/:id", (req, res) => {
      res.status(200).send({ id: req.params.id, name: req.body.name });
    });

Delete

Add the following test case to app.test.js

it("should handle delete request", async () => {
    const res = await request(server.app).delete("/resource/55");
    expect(res.status).to.equal(204);
});

This test will fail because we don't have a handler for the DELETE method. In order to make the test pass we'll add the following content to app.js.

this.app.delete("/resource/:id", (req, res) => {
      res.status(204).send();
    });

Get parameters

We can also test other functionality such as handling get parameters. Add the following test case to app.test.js

it("should handle a get request with parameters", async () => {
  const res = await request(server.app)
    .get("/search")
    .query({ name: "test" });
    expect(res.status).to.equal(200);
    expect(res.body).to.deep.equal([{ id: "1", name: "test" }]);
});

This test will fail because we don't have a handler for the /search route. In order to make the test pass we'll add the following content to app.js.

this.app.get("/search", (req, res) => {
  if (req.query.name === "test") {
    res.status(200).send([{ id: "1", name: "test" }]);
  } else {
    res.status(200);
  }
});

Testing edge cases and error handling

Make sure to test scenarios where you expect errors, such as invalid inputs or unauthorized requests:

it('should return a 400 error for invalid input', async () => {
  const res = await request(app).post('/resource').send({ wrongKey: 'Wrong value' });
  expect(res.status).to.equal(400);
  expect(res.body).to.have.property('error');
});

This test will fail since we don't check that the request matches the expected format. In order to make the test pass we'll add the following content to app.js.

app.post("/resource", (req, res) => {
  if ("name" in req.body) {
    res.status(201).send({ id: "1", name: req.body.name });
  } else {
    res.status(400).send({ error: "error" });
  }
});

Lifecycle methods

Mocha provides hooks like before, after, beforeEach, and afterEach to set up or clean up resources before and after your tests. This can be useful for preparing test data or cleaning up test artifacts:

before(async () => {
  // Set up test data
});

after(async () => {
  // Clean up test data
});

beforeEach(async () => {
  // Prepare for each test
});

afterEach(async () => {
  // Clean up after each test
});

To keep tests independent the server should be recreated before each test and destroyed after. In addition, when using test-watch if the server is not explicitly cleaned up before the tests are rerun the server may still exist before the modules are reloaded. This means that the port would still be bound when we create a new server and cause the tests to fail.

We need to refactor the server to have lifecycle methods that we can call from the test hooks.

//app.test.js
const request = require("supertest");
const app = require("../app.js");
const { expect, assert } = require("chai");

describe("GET /", () => {
  let server;
  beforeEach(async () => {
    server = new app();
    await server.start();
  });

  afterEach(async () => {
    await server.stop();
  });

  it('should respond with "Hello, World!"', async () => {
    const res = await request(server.app).get("/");
    expect(res.status).to.equal(200);
    expect(res.text).to.equal("Hello, World!");
  });
  it("should create a new resource", async () => {
    const res = await request(server.app)
      .post("/resource")
      .send({ name: "New resource" });
    expect(res.status).to.equal(201);
    expect(res.body).to.have.property("id");
    expect(res.body.name).to.equal("New resource");
  });
  it("should return a 400 error for invalid input", async () => {
    const res = await request(server.app)
      .post("/resource")
      .send({ wrongKey: "Wrong value" });
    expect(res.status).to.equal(400);
    expect(res.body).to.have.property("error");
  });
  it("should handle a get request with parameters", async () => {
    const res = await request(server.app)
      .get("/search")
      .query({ name: "test" });
    expect(res.status).to.equal(200);
    expect(res.body).to.deep.equal([{ id: "1", name: "test" }]);
  });
  it("should handle delete request", async () => {
    const res = await request(server.app).delete("/resource/55");
    expect(res.status).to.equal(204);
  });
  it("should handle put request", async () => {
    const res = await request(server.app)
      .put("/resource/55")
      .send({ name: "person" });
    expect(res.status).to.equal(200);
    expect(res.body).to.deep.equal({ id: "55", name: "person" });
  });
});
//app.js
const express = require("express");

class serverApp {
  constructor() {
    this.app = express();

    this.app.use(express.json());

    this.app.get("/", (req, res) => {
      res.status(200).send("Hello, World!");
    });

    this.app.get("/search", (req, res) => {
      if (req.query.name === "test") {
        res.status(200).send([{ id: "1", name: "test" }]);
      } else {
        res.status(200);
      }
    });

    this.app.post("/resource", (req, res) => {
      if ("name" in req.body) {
        res.status(201).send({ id: "1", name: req.body.name });
      } else {
        res.status(400).send({ error: "error" });
      }
    });

    this.app.delete("/resource/:id", (req, res) => {
      res.status(204).send();
    });

    this.app.put("/resource/:id", (req, res) => {
      res.status(200).send({ id: req.params.id, name: req.body.name });
    });
  }

  start() {
    const serverPromise = new Promise((resolve) => {
      this.server = this.app.listen(3000, () => {
        console.log("Server is running on port 3000");
        resolve();
      });
    });
    return serverPromise;
  }

  stop() {
    const serverPromise = new Promise((resolve) => {
      this.server.close(() => {
        resolve();
      });
    });
    return serverPromise;
  }
}

module.exports = serverApp;

By consistently writing tests for your Node HTTP API using the Supertest library, you can ensure that your API remains stable and reliable as it grows in complexity. Happy testing!