Testing in Nodejs with Jest and Supertest

Testing in Nodejs with Jest and Supertest

Testing is essential for the overall quality and reliability of software. Testing can help prevent bugs, errors, ensure quality and help with setting up continuous integration.

Jest is a popular testing framework that can be used to write tests in Javascript. In this blog let's learn all about testing in javascript.

Setup

To install Jest using npm ,run the following command after initialising a project-

npm install --save-dev jest

Create a file named index.test.js to write your tests. You can create multiple test files as well.

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

Add the above test script and use "npm test" to run all .test.js files present in your project.

Some Basic tests

Here are some basic tests to help you get an idea before we go deeper.

// testing basic functions
function hello() {
  return "hello";
}
function sum(a, b) {
  return a + b;
}
test("should return  hello", () => {
  expect(hello()).toBe("hello");
});
it("should return sum", () => {
  expect(sum(1, 2)).toBe(3);
});

The output would look like the following-

 PASS  ./index.test.js
  √ should return hello (6 ms)
  √ should return sum (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.221 s, estimated 2 s
Ran all test suites.

You can use "test" or "it" to write tests for a particular condition, like in the above case we wrote tests for two functions, hello() and sum().

We use expect to write assertions in our test cases. You can write multiple expectations for a test, eg.- for the sum(a,b) function you can write multiple expectations with different values of a and b. If any of the expectations fail inside a test, the entire test would fail like the following-

test("should return hello", () => {
  expect(hello()).toBe("hello");
});
it("should return sum", () => {
  //testing the sum function with different values
  expect(sum(1, 2)).toBe(3);
  expect(sum(-1, -2)).toBe(3); //this expectation will fail
  expect(sum(0, 0)).toBe(0);
});

 FAIL  ./index.test.js
  √ should return hello (9 ms)
  × should return sum (10 ms)                                                                                         

  ● should return sum                                                                                                 

    expect(received).toBe(expected) // Object.is equality

    Expected: 3
    Received: -3

      11 | it("should return sum", () => {
      12 |   expect(sum(1, 2)).toBe(3);
    > 13 |   expect(sum(-1, -2)).toBe(3);
         |                       ^
      14 |   expect(sum(0, 0)).toBe(0);
      15 | });
      16 |

      at Object.toBe (index.test.js:13:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.969 s, estimated 2 s

Grouping testcases using describe

//grouping testcases
const apple = {
  sour: false,
  sweet: true,
  tasty: true,
};
describe("apple should be", () => {
  test("sweet", () => {
    expect(apple.sweet).toBeTruthy();
  });
  test("not sour", () => {
    expect(apple.sour).toBeFalsy();
  });
  test("tasty", () => {
    expect(apple.tasty).toBeTruthy();
  });
});
 PASS  ./index.test.js
  apple should be
    √ sweet (7 ms)                                                                                                    
    √ not sour
    √ tasty (2 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.059 s, estimated 5 s
Ran all test suites.

We use describe to group multiple testcases under one test suite. In the above example, we are checking all expected properties of apple in one test suite.

In all of the above examples, you can see we have used functions such as toBe(), toBeTruthy() which help us make assertions.

.toBe(), .tobeTruthy() etc. are matchers, the role of a matcher is to match the value on the left to the value on the right in certain ways depending on which matcher we use. Let's explore this.

Matchers

Commonly used matchers

//testing some matchers, all the tests will pass
describe("null should", () => {
  const variable = null;
  test("equal null", () => {
    expect(variable).toBe(null);
    expect(variable).toEqual(null);
  });
  test("be defined", () => {
    //since null is defined
    expect(variable).not.toBeUndefined();
    expect(variable).toBeDefined();
  });
  test("be false", () => {
    expect(variable).toBeFalsy();
  });
});
 PASS  ./index.test.js
  null should
    √ equal null (9 ms)
    √ be defined (1 ms)
    √ be false (1 ms)                                                                                                 

Test Suites: 1 passed, 1 total                                                                                        
Tests:       3 passed, 3 total                                                                                        
Snapshots:   0 total
Time:        1.492 s
Ran all test suites.

Difference between toBe and toEqual

const obj1 = {
  sour: true,
  tasty: true,
};
let obj2 = {
  sour: true,
  tasty: true,
};
describe("obj1 and obj2", () => {
  test("are not the same object", () => {
    expect(obj1).toBe(obj2);
  });
  test("have the same values", () => {
    expect(obj1).toEqual(obj2);
  });
});

toBe() will fail here since obj1 and obj2 are not the same object, toEqual() however compares the values of obj1 and obj2 hence it will pass.

 FAIL  ./index.test.js
  obj1 and obj2
    × are not the same object (18 ms)
    √ have the same values (1 ms)                                                                                     

  ● obj1 and obj2 › are not the same object                                                                           

    expect(received).toBe(expected) // Object.is equality

    If it should pass with deep equality, replace "toBe" with "toStrictEqual"

    Expected: {"sour": true, "tasty": true}
    Received: serializes to the same string

      71 | describe("obj1 and obj2", () => {
      72 |   test("are not the same object", () => {
    > 73 |     expect(obj1).toBe(obj2);
         |                  ^
      74 |   });
      75 |   test("have the same values", () => {
      76 |     expect(obj1).toEqual(obj2);

      at Object.toBe (index.test.js:73:18)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.695 s, estimated 2 s
Ran all test suites.

Repeating/One-time operation setup

If there is something you have to do repeatedly before every test, after every test or once before all the tests etc., Jest offers a way to do repeated operations.

Setups follow scope, means a beforeEach() declared inside a describe block will only be applicable to the tests inside that describe block.

const obj1 = {
  sour: true,
  tasty: true,
};
beforeEach(() => {
  if (obj1.sour == false) {
    obj1.sour = true;
  } else {
    obj1.sour = false;
  }
});
describe("obj1 should be ", () => {
  test("not sour", () => {
    expect(obj1.sour).toBeFalsy();
  });
  test("not sour", () => {
    expect(obj1.sour).toBeFalsy();
  });
});
 FAIL  ./index.test.js
  obj should be 
    √ not sour (6 ms)                                                                                                 
    × not sour (2 ms)                                                                                                 

  ● obj1 should be  › not sour                                                                                         

    expect(received).toBeFalsy()

    Received: true

      53 |   });
      54 |   test("not sour", () => {
    > 55 |     expect(obj1.sour).toBeFalsy();
         |                       ^
      56 |   });
      57 | });
      58 |

      at Object.toBeFalsy (index.test.js:55:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.178 s, estimated 4 s
Ran all test suites.

The value of obj1.sour is switching before every test hence the second test fails.

Testing a Node server with Jest and Supertest

To test a node server with a mongoDB database which stores user details and fetches them, the following might be some things you will want to test:

  1. How the server responds if we don't send user details with the POST request.

  2. Test if the data is being entered with a status of 200.

  3. Test if the entered user is being retrieved with a status 200.

  4. Test if the server responds appropriately to an invalid username query.

  5. Verify if the returned data is in JSON.

To test API endpoints, you can either use an HTTP library like axios or a special HTTP testing library like supertest. Both of these can be used with jest.

Testing with Supertest vs testing with standard HTTP libraries like Axios

Supertest tests API endpoints in isolation, you do not need to actually deploy your server in order to test your API endpoints.

Axios is a popular HTTP library, however it is not specialised for testing HTTP servers. With axios if you wish to test a server the server will have to be deployed somewhere. Axios can also be affected by things like rate limiting if your server implements it.

Hence when it comes to testing, supertest proves to be better than standard http libraries.

Testing a node server

Let us test the following two API endpoints-

app.post("/register", async (req, res) => {
  const { username, age, city } = req.body;
  if (!username || !age || !city) {
    return res.status(400).json({ success: false, message: "Invalid user" });
  }
  var newUser = new User({
    username,
    age,
    city,
  });
  //save to mongoDB
  const savedUser = await newUser.save();
  return res.status(200).json({ success: true, message: savedUser });
});

app.get("/get-user", async (req, res) => {
  const { username } = req.body;
  if (!username || !age || !city) {
    return res.status(400).json({ success: false, message: "Invalid user" });
  }
  const users = await User.find({ username });

  if (users.length > 0) {
    return res.status(200).json({ success: true, users });
  }
  return res.status(404).json({ success: false });
});

To write tests, import supertest and app.

const app = require("./index");
const request = require("supertest");
describe("POST /register", () => {
  test("should return status 400 on empty user details", (done) => {
    request(app)
      .post("/register")
      .send({})
      .expect("Content-Type", /json/)
      .expect(400, done);
  });
  test("should return a status of 200 on entering user details", (done) => {
    request(app)
      .post("/register")
      .send({ username: "adit", city: "ggn", age: 21 })
      .expect("Content-Type", /json/)
      .expect(200, done);
  });
});

The above two tests can be written for the /register route handler, you can try to write more tests based on edgecases. In the above tests I am checking for empty user details, and checking the successful entry of user details. Do note that in this case we are making an entry to the database, so we need to delete that after every test.

As you may see supertest helps with making HTTP requests in an isolated environment without the need for deploying on local. Let's write tests for the get-user route handler.

describe("GET /get-user", () => {
  test("should return status 400 on empty user details", (done) => {
    request(app)
      .get("/get-user")
      .send({})
      .expect("Content-Type", /json/)
      .expect(400, done);
  });
  test("should return status 404 on invalid user", (done) => {
    request(app)
      .get("/get-user")
      .send({ username: "etri" })
      .expect("Content-Type", /json/)
      .expect(404, done);
  });
  test("should return status 200 on retrieving user", (done) => {
    request(app)
      .get("/get-user")
      .send({ username: "aditya" })
      .expect("Content-Type", /json/)
      .expect(200, done);
  });
});

That's all for this one, hope you learnt something and thank you for reading !!