API Testing with Mocha, Chai and Supertest in NodeJs.

API Testing with Mocha, Chai and Supertest in NodeJs.

Introduction

Testing is essential today for building a robust application that may change frequently or extend later if you are learning Test Driven Development or looking to add API test cases in the backend, which may improve the debugging and testing time of the application. In NodeJs we have a lot of testing tools like Jest and Mocha. In this tutorial we are going to use mocha and chai with supertest to create test cases and run them after adding a feature, deleting a feature, or changing something to see if it breaks anything. Note that we need chai along with mocha because it is an assertion library that provides us with many ways to write assertions. Supertest is an HTTP testing library that we will use along with mocha and chai.

Installation

First, you need to install all the required libraries for testing. We are using Mocha, Chai, and Supertest for testing the APIs. We are installing them as dev dependencies to use them during development.

npm install --save-dev mocha chai supertest

Test Script Setup

create a mocha test script in package.json.

package.json

  "scripts": {
    "test": "mocha"
  },

Todo API Example

We are using a basic todo example. In this example, we are using express.js to create server and private routes for todo CRUD operation.

User Data

We need user data to authenticate and authorize users who can use the todo APIs. This is similar to having a user table.

const verifyUser = (req, res, next) => {
  if (req.headers.userkey === undefined || req.headers.userkey === "") return res.sendStatus(401);
  for (let user of users) {
    if (user.id == req.headers.userkey) {
      req.user = user;
      break;
    }
  }
  if (!req.user) return res.sendStatus(401);
  next();
};

Authorization Middleware Setup

We need to make sure the requests were made by registered users only, so for simplicity, we will use their id in the headers to authorize the user's request. This is similar to using JWT as an authorization header after authentication.

const verifyUser = (req, res, next) => {
  if (req.headers.userkey === undefined || req.headers.userkey === "") return res.sendStatus(401);
  for (let user of users) {
    if (user.id == req.headers.userkey) {
      req.user = user;
      break;
    }
  }
  if (!req.user) return res.sendStatus(401);
  next();
};

Test Setup

We need to create tests for two routes, Create todo and Delete todo. To let mocha discover the test files we need to create a test directory and inside it, we can put all of our test files. We will create todo.api.js inside the test directory.

We use the describe function to describe a function and it to create the test case which will run with assertions set up in it. describe takes two parameters, 1. Description of the whole test case 2. Callback Function which can take it statements or other describe functions. The it function takes the description of what the test case does in the first parameter and a callback function which is used to set up assertions.

Chai provides us many assertion styles like assert, should, and expect. We are using expect in this tutorial.

1. create before function

The before function runs before all tests in the file. It's mostly used to set the user authorization token received after login to test protected APIs. In our case, we are using the userkey as the user authorization key.

test.api.js

const chai = require("chai");
const request = require("supertest");
const expect = chai.expect;
const app = require("../server");

describe("Todo Routes", () => {
  let testUser;

  before(async () => {
    // This code wil run before all other test cases in this file. Use api code to get user authorization token in this block.
    // we are using userkey in our case. It can be replaced with jwt.
    testUser = {
      userkey: 1,
    };
  });

});

2. write tests for todos APIs

Now the next step is to write down test cases for the API according to the design we have thought. We are going to create GET /todos and Post /todos API route test cases.

// describe a function with a description
describe("Todo Routes", () => {
  let testUser;

  before(async () => {
    // This code wil run before all other test cases in this file. Use api code to get user authorization token in this block.
    // we are using userkey in our case. It can be replaced with jwt.
    testUser = {
      userkey: 1,
    };
  });

  describe("POST /todos", () => {
    const todo = { title: "first todo", description: "first description" };

    // it is used to write a test case
    it("returns unauthorized", async () => {
      const res = await request(app).post("/todos").send(todo).set("Accept", "application/json");

      // you expect the api to return 401 without userKey
      expect(res.status).to.be.equal(401);
    });

    it("creates a todo", async () => {
      const res = await request(app).post("/todos").send(todo).set("userkey", testUser.userkey).set("Accept", "application/json");

      // match the expected respose which is 201 if a resource is created on server and the values sent.
      expect(res.status).to.be.equal(201);
      expect(res.body).to.be.an("object").with.keys("id", "title", "description");
      expect(res.body.id).to.be.a("number");
      expect(res.body.title).to.be.equal(todo.title);
      expect(res.body.description).to.be.equal(todo.description);
    });
  });

  describe("GET /todos", () => {
    it("returns unauthorized", async () => {
      const res = await request(app).get("/todos").set("Accept", "application/json");

      expect(res.status).to.be.equal(401);
    });

    it("returns todos", async () => {
      const res = await request(app).get("/todos").set("userkey", testUser.userkey).set("Accept", "application/json");

      expect(res.status).to.be.equal(200);
      res.body.todos.map((todo) => {
        expect(todo).to.be.an("object").with.keys("id", "title", "description");
      });
    });
  });

  describe("PUT /todos/:id", () => {
    const todo = { title: "updated todo title", description: "updated todo description" };
    it("returns unauthorized", async () => {
      const res = await request(app)
        .put("/todos/" + 1)
        .send(todo)
        .set("Accept", "application/json");

      expect(res.status).to.be.equal(401);
    });

    it("updates todo", async () => {
      const res = await request(app)
        .put("/todos/" + 1)
        .send(todo)
        .set("userkey", testUser.userkey)
        .set("Accept", "application/json");

      expect(res.status).to.be.equal(200);
      expect(res.body).to.be.an("object").with.keys("id", "title", "description");
      expect(res.body.id).to.be.a("number");
      expect(res.body.title).to.be.equal(todo.title);
      expect(res.body.description).to.be.equal(todo.description);
    });
  });

  describe("DELETE /todos/:id", () => {
    it("returns unauthorized", async () => {
      const res = await request(app).delete("/todos/" + 1);

      expect(res.status).to.be.equal(401);
    });

    it("deletes todo", async () => {
      const res = await request(app)
        .delete("/todos/" + 1)
        .set("userkey", testUser.userkey);

      expect(res.status).to.be.equal(204);
    });
  });
});

3. create API routes and todos CRUD logic

As we have setup test cases and following the concept of Test Driven Development, now we need to create routes and functions to create, get, update and delete todos.

Here we are using verifyuser middleware before todo logic to make the routes protected.

let todos = [];
router.post("/todos", verifyUser, (req, res) => {
  const user = req.user;
  let todo = { id: todos.length + 1, userId: user.id, title: req.body.title, description: req.body.description };
  todos.push(todo);
  res.status(201).json({ id: todo.id, title: todo.title, description: todo.description });
});

router.get("/todos", verifyUser, (req, res) => {
  const user = req.user;
  let filterTodos = todos.filter((todo) => todo.userId === user.id);
  res.status(200).json({
    todos: filterTodos.map((todo) => ({ id: todo.id, title: todo.title, description: todo.description })),
  });
});

router.put("/todos/:id", verifyUser, (req, res) => {
  const user = req.user;
  let updatedTodo;
  for (let todo of todos) {
    if (Number(req.params.id) === todo.id && todo.userId === user.id) {
      todo.title = req.body.title;
      todo.description = req.body.description;
      updatedTodo = todo;
      break;
    }
  }
  if (!updatedTodo) return res.status(404).json({ error: "not found!", message: "todo not found!" });

  res.status(200).json({ id: updatedTodo.id, title: updatedTodo.title, description: updatedTodo.description });
});

router.delete("/todos/:id", verifyUser, (req, res) => {
  const user = req.user;
  let index = -1;
  for (let i = 0; i < todos.length; i++) {
    if (todos[i].id === Number(req.params.id)) {
      index = i;
    }
  }
  todos.splice(index, 1);

  res.sendStatus(204);
});

4. setup the test command and run the test cases

Wrap up the code and start testing using the npm test command from the terminal in the project directory.

npm test

The test results will be the following:

todos_crud_test.PNG

Note: In case any test don't pass, you should check what's wrong with the code and fix and re-test.

Complete Snippets

server.js

const express = require("express");
const app = express();
const router = express.Router();

app.use(express.json());

const users = [
  {
    id: 1,
    name: "John",
  },
  { id: 2, name: "Mary" },
  { id: 3, name: "Sara" },
];

const verifyUser = (req, res, next) => {
  if (req.headers.userkey === undefined || req.headers.userkey === "") return res.sendStatus(401);
  for (let user of users) {
    if (user.id == req.headers.userkey) {
      req.user = user;
      break;
    }
  }
  if (!req.user) return res.sendStatus(401);
  next();
};

app.use("/", router);

let todos = [];
router.post("/todos", verifyUser, (req, res) => {
  const user = req.user;
  let todo = { id: todos.length + 1, userId: user.id, title: req.body.title, description: req.body.description };
  todos.push(todo);
  res.status(201).json({ id: todo.id, title: todo.title, description: todo.description });
});

router.get("/todos", verifyUser, (req, res) => {
  const user = req.user;
  let filterTodos = todos.filter((todo) => todo.userId === user.id);
  res.status(200).json({
    todos: filterTodos.map((todo) => ({ id: todo.id, title: todo.title, description: todo.description })),
  });
});

router.put("/todos/:id", verifyUser, (req, res) => {
  const user = req.user;
  let updatedTodo;
  for (let todo of todos) {
    if (Number(req.params.id) === todo.id && todo.userId === user.id) {
      todo.title = req.body.title;
      todo.description = req.body.description;
      updatedTodo = todo;
      break;
    }
  }
  if (!updatedTodo) return res.status(404).json({ error: "not found!", message: "todo not found!" });

  res.status(200).json({ id: updatedTodo.id, title: updatedTodo.title, description: updatedTodo.description });
});

router.delete("/todos/:id", verifyUser, (req, res) => {
  const user = req.user;
  let index = -1;
  for (let i = 0; i < todos.length; i++) {
    if (todos[i].id === Number(req.params.id)) {
      index = i;
    }
  }
  todos.splice(index, 1);

  res.sendStatus(204);
});

app.listen(4000, () => {
  console.log(`app listening on port ${4000}`);
});

module.exports = app;

test.api.js

const chai = require("chai");
const request = require("supertest");
const expect = chai.expect;
const app = require("../server");

// describe a function with a description
describe("Todo Routes", () => {
  let testUser;

  before(async () => {
    // This code wil run before all other test cases in this file. Use api code to get user authorization token in this block.
    // we are using userkey in our case. It can be replaced with jwt.
    testUser = {
      userkey: 1,
    };
  });

  describe("POST /todos", () => {
    const todo = { title: "first todo", description: "first description" };

    // it is used to write a test case
    it("returns unauthorized", async () => {
      const res = await request(app).post("/todos").send(todo).set("Accept", "application/json");

      // you expect the api to return 401 without userKey
      expect(res.status).to.be.equal(401);
    });

    it("creates a todo", async () => {
      const res = await request(app).post("/todos").send(todo).set("userkey", testUser.userkey).set("Accept", "application/json");

      // match the expected respose which is 201 if a resource is created on server and the values sent.
      expect(res.status).to.be.equal(201);
      expect(res.body).to.be.an("object").with.keys("id", "title", "description");
      expect(res.body.id).to.be.a("number");
      expect(res.body.title).to.be.equal(todo.title);
      expect(res.body.description).to.be.equal(todo.description);
    });
  });

  describe("GET /todos", () => {
    it("returns unauthorized", async () => {
      const res = await request(app).get("/todos").set("Accept", "application/json");

      expect(res.status).to.be.equal(401);
    });

    it("returns todos", async () => {
      const res = await request(app).get("/todos").set("userkey", testUser.userkey).set("Accept", "application/json");

      expect(res.status).to.be.equal(200);
      res.body.todos.map((todo) => {
        expect(todo).to.be.an("object").with.keys("id", "title", "description");
      });
    });
  });

  describe("PUT /todos/:id", () => {
    const todo = { title: "updated todo title", description: "updated todo description" };
    it("returns unauthorized", async () => {
      const res = await request(app)
        .put("/todos/" + 1)
        .send(todo)
        .set("Accept", "application/json");

      expect(res.status).to.be.equal(401);
    });

    it("updates todo", async () => {
      const res = await request(app)
        .put("/todos/" + 1)
        .send(todo)
        .set("userkey", testUser.userkey)
        .set("Accept", "application/json");

      expect(res.status).to.be.equal(200);
      expect(res.body).to.be.an("object").with.keys("id", "title", "description");
      expect(res.body.id).to.be.a("number");
      expect(res.body.title).to.be.equal(todo.title);
      expect(res.body.description).to.be.equal(todo.description);
    });
  });

  describe("DELETE /todos/:id", () => {
    it("returns unauthorized", async () => {
      const res = await request(app).delete("/todos/" + 1);

      expect(res.status).to.be.equal(401);
    });

    it("deletes todo", async () => {
      const res = await request(app)
        .delete("/todos/" + 1)
        .set("userkey", testUser.userkey);

      expect(res.status).to.be.equal(204);
    });
  });
});