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:
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);
});
});
});
Subscribe to my newsletter
Read articles from Gaurav Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by