Search code examples
node.jsunit-testingjestjsmockingsinon

Having trouble stubbing an asynchronous function


I am currently working on a full-stack application using the MERN stack. I have been writing unit tests for each route's request handler, and have been finding some difficulty testing error cases, particularly in trying to stub a function to reject a promise

I have the relevant code shown below:

One of my endpoints. Request handling is delegated to userController

const express = require("express");
const { body } = require("express-validator");

const router = express.Router();

const userController = require("../../controllers/user");

router.post(
  "/",
  body("username")
    .isLength({
      min: 3,
      max: 30,
    })
    .withMessage(
      "Your username must be at least 3 characters and no more than 30!"
    ),
  body("password")
    .isLength({ min: 3, max: 50 })
    .withMessage(
      "Your password must be at least 3 characters and no more than 50!"
    ),
  userController.createNewUser
);

The request handler for the above endpoint. I am trying to test createNewUser. I want to stub createNewUser so that it causes an error to be thrown, so I can test that a 500 status code response is sent.

const bcrypt = require("bcryptjs");
const { validationResult } = require("express-validator");

const User = require("../models/User");

exports.createNewUser = async (req, res, next) => {
  const { username, password } = req.body;
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array(),
    });
  }

  try {
    // Create a bcrypt salt
    const salt = await bcrypt.genSalt(12);

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, salt);

    // Create a new user
    const user = new User({
      username,
      password: hashedPassword,
    });

    const response = await user.save();

    res.status(200).json(response);
  } catch (err) {
    res.status(500).json({ msg: err.message });
  }
};

The unit test for User endpoints. I am unsure how to test the error case where a 500 status code is returned...

const request = require("supertest");

// const todosController = require("../controllers/todos");
const server = require("../server");
const User = require("../models/TodoItem");
const db = require("./db");

const agent = request.agent(server);

// Setup connection to the database
beforeAll(async () => await db.connect());
afterEach(async () => await db.clear());
afterAll(async () => await db.close());

describe("User endpoints test suite", () => {
  describe("POST api/user", () => {
    test("It should create a user successfully and return a 200 response code", async () => {
      const response = await agent
        .post("/api/user")
        .set("content-type", "application/json")
        .send({ username: "Bob", password: "12345" });
      expect(response.body.username).toEqual("Bob");
      expect(response.status).toBe(200);
    });
});
});


Solution

  • When you are creating unit test, create something small first, you can add complexity and refactor later.

    Below are example simple unit and integration tests based on your code.

    You can start with user controller.

    // File: user.controller.js
    const bcrypt = require('bcryptjs');
    
    exports.createNewUser = async (req, res) => {
      try {
        // Create a bcrypt salt.
        const salt = await bcrypt.genSalt(12);
        // Just make it simple, show the salt.
        res.status(200).json(salt);
      } catch (err) {
        // Other wise, return the error message.
        res.status(500).json({ msg: err.message });
      }
    };
    

    Based on that try and catch, you can create unit test.

    // File: user.controller.spec.js
    const bcrypt = require('bcryptjs');
    const user = require('./user.controller');
    
    describe('User Controller', () => {
      describe('create New User', () => {
        const fakeJson = jest.fn();
        const fakeStatus = jest.fn().mockReturnThis();
        const fakeRes = {
          status: fakeStatus,
          json: fakeJson,
        };
        const spy = jest.spyOn(bcrypt, 'genSalt');
    
        afterEach(() => {
          jest.clearAllMocks();
        });
    
        it('should return salt', async () => {
          const testSalt = 'salt';
          // Mock the bcrypt.genSalt, always resolved with value testSalt.
          spy.mockResolvedValue(testSalt);
          // Call the function under test.
          await user.createNewUser(undefined, fakeRes);
          // Set the expectations.
          expect(fakeStatus).toHaveBeenCalledWith(200);
          expect(fakeJson).toHaveBeenCalledWith(testSalt);
          expect(spy.mock.calls[0][0]).toBe(12);
        });
    
        it('should return error message when error', async () => {
          const error = new Error('XXX');
          // Mock the bcrypt.genSalt, always resolved with value testSalt.
          spy.mockRejectedValue(error);
          // Call the function under test.
          await user.createNewUser(undefined, fakeRes);
          // Set the expectations.
          expect(fakeStatus).toHaveBeenCalledWith(500);
          expect(fakeJson).toHaveBeenCalledWith({ msg: error.message });
          expect(spy.mock.calls[0][0]).toBe(12);
        });
      });
    });
    

    When you run it on terminal:

    $ npx jest user.controller.spec.js 
     PASS  ./user.controller.spec.js
      User Controller
        create New User
          ✓ should return salt (5 ms)
          ✓ should return error message when error (1 ms)
    
    --------------------|---------|----------|---------|---------|-------------------
    File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
    --------------------|---------|----------|---------|---------|-------------------
    All files           |     100 |      100 |     100 |     100 |                   
     user.controller.js |     100 |      100 |     100 |     100 |                   
    --------------------|---------|----------|---------|---------|-------------------
    Test Suites: 1 passed, 1 total
    Tests:       2 passed, 2 total
    Snapshots:   0 total
    Time:        0.511 s, estimated 1 s
    Ran all test suites matching /user.controller.spec.js/i.
    

    Next, if you have sure with your controller, you can create integration test with express.

    For example you create app index like this.

    // File: index.js
    const express = require('express');
    
    const userController = require('./user.controller');
    
    const router = express.Router();
    
    router.post('/user', (req, res, next) => userController.createNewUser(req, res, next));
    
    const app = express();
    
    app.use('/api', router);
    
    module.exports = app;
    

    You can test it using jest for normal & error case like this.

    // File: index.spec.js
    const request = require('supertest');
    const bcrypt = require('bcryptjs');
    const server = require('./index');
    const userController = require('./user.controller');
    
    const agent = request.agent(server);
    
    describe('App', () => {
      describe('POST /', () => {
        // Create spy on bcrypt.
        const spy = jest.spyOn(bcrypt, 'genSalt');
        const error = new Error('XXX');
    
        afterEach(() => {
          jest.clearAllMocks();
        });
    
        it('should create a salt successfully and return a 200 response code', async () => {
          // This test is slow because directly call bcrypt.genSalt.
          // To make it faster, mock bcrypt completely, or use spy.mockResolvedValue('SALT');
          // Send post request.
          const response = await agent.post('/api/user');
          // Make sure the response.
          expect(response.status).toBe(200);
          expect(response.type).toBe('application/json');
          expect(spy.mock.results[0].value).toBeDefined();
          const spyResult = await spy.mock.results[0].value;
          expect(response.body).toBe(spyResult)
        });
    
        it('should return 500 and error message when catch error', async () => {
          // Makesure spy reject.
          spy.mockRejectedValue(error);
          // Send post request.
          const response = await agent.post('/api/user');
          // Make sure the response.
          expect(response.status).toBe(500);
          expect(response.type).toBe('application/json');
          expect(response.body).toBeDefined();
          expect(response.body.msg).toBeDefined();
          expect(response.body.msg).toBe(error.message);
        });
    
        // Or play around with another spy to error alternatives.
        it('should return 404 when pass to next', async () => {
          // Makesure createNewUser error.
          jest.spyOn(userController, 'createNewUser').mockImplementation((req, res, next) => {
            // You can setup res here or other implementation to check.
            // For example, do next.
            next();
          });
    
          // Send post request.
          const response = await agent.post('/api/user');
          // Make sure the response.
          expect(response.status).toBe(404);
          // Method bcrypt.genSalt should not get called.
          expect(spy).not.toHaveBeenCalled();
        });
      });
    });
    

    When you run it from terminal:

    $ npx jest index.spec.js 
     PASS  ./index.spec.js
      App
        POST /
          ✓ should create a salt successfully and return a 200 response code (40 ms)
          ✓ should return 500 and error message when catch error (4 ms)
          ✓ should return 404 when pass to next (5 ms)
    
    --------------------|---------|----------|---------|---------|-------------------
    File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
    --------------------|---------|----------|---------|---------|-------------------
    All files           |     100 |      100 |     100 |     100 |                   
     index.js           |     100 |      100 |     100 |     100 |                   
     user.controller.js |     100 |      100 |     100 |     100 |                   
    --------------------|---------|----------|---------|---------|-------------------
    Test Suites: 1 passed, 1 total
    Tests:       3 passed, 3 total
    Snapshots:   0 total
    Time:        0.809 s, estimated 1 s
    Ran all test suites matching /index.spec.js/i.
    

    Note: You do not need to use sinon, jest provides mock functions.