Unit test in javascript: bests practices

One of the best practices in javascript unit testing is to decouple the tests. This is to minimize the dependencies between different tests.

Concretely each test block must have defined its dependencies independently others test blocks.

Let’s go to a concrete example

Bellow a class that manage notifications:

const config = require('./config');

class Notification {
  constructor () {
    this.timeout = config.timeout;
    this.notifContainer = document.getElementById('notif-container');

    this.addNotificationsEventListeners();
  }

  /**
   * addEventListener to notify user displaying a notification box
   */
  addNotificationsEventListeners () {
    // addEventListener to notify user
    addEventListener('onUserNotify', this.notifyUser.bind(this));

    // Close notification box when user clicks on the page
    addEventListener('click', (event) => {
      if (event.target === this.notifContainer) {
        this.deleteNotification();
      }
    });
  }

  /**
   * Notify user displaying a notification box
   *
   * Notification types:
   *  'error' => display error notification box
   *  'succes' => display succes notification box
   *  'info' => display info notification box
   *
   * @param event
   */
  notifyUser (event) {
    if (!event.message) {
      return;
    }

    this.message = event.message;
    this.timeout = event.timeout ? event.timeout : this.timeout;

    this.deleteNotification();
    this.createNotificationBox();
    this.resizeNotification();

    // Close notification box when user click on button close
    const notifContainerClose = document.querySelector('.notif-container-close');

    notifContainerClose.addEventListener('click', this.deleteNotification.bind(this));
  }

  /**
   * Resize notification box
   */
  resizeNotification () {
    const notifContainer = document.querySelector('.notif-container');
    const notifContainerContent = document.querySelector('.notif-container-content');

    notifContainerContent.style.position = 'relative';
    notifContainerContent.style.top = (notifContainer.clientHeight / 2 - notifContainerContent.clientHeight / 2) + 'px';
  }

  /**
   * Delete notification box
   */
  deleteNotification () {
    if (document.body.contains(this.notifContainer)) {
      document.body.removeChild(this.notifContainer);
    }
  }

  /**
   * Create notification box
   */
  createNotificationBox () {
    const notifContainerContent = `
                    <div class="notif-container-content">
                        <span class="notif-container-close">X</span>
                        <p>${this.message}</p>
                    </div>
                  `;

    // Create node element 'notif-container'
    this.notifContainer = Object.assign(document.createElement('div'), {
      className: 'notif-container',
      id: 'notif-container',
      innerHTML: notifContainerContent
    });

    // Add 'notif-container' to html
    document.body.appendChild(this.notifContainer);

    // Add timeout to delete notification box
    setTimeout(() => { 
      this.deleteNotification();
    }, this.timeout);
  }
}

module.exports = Notification;

To see more, check out the project in my Github repository: https://github.com/oumarkonate/unit-tests-javascript

Example of bad practice

The following unit test is not a good practice because the coupling is strong between different tests.

const chai = require('chai'),
      sinon = require('sinon'),
      jsdom = require("jsdom"),
      { JSDOM } = jsdom;

const Notification = require('../notification');

describe('#Test Notification', () => {
  const sandBox = sinon.createSandbox(), 
        expect = chai.expect,
        dom = new JSDOM('');        

  let notification, 
      stubQuerySelector;

  window = dom.window;
  document = dom.window.document;

  beforeEach(() => {
    stubQuerySelector = sandBox.stub(document, 'querySelector');
    sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
    
    notification = new Notification();
  });

  afterEach(() => {
    if (sandBox.restore) {
      sandBox.restore();
    }
  });

  it('Should test constructor', () => {
    expect(notification).to.have.property('timeout').to.equal(5000);
    expect(notification).to.have.property('notifContainer');
    expect(notification.addNotificationsEventListeners.calledOnce).to.be.true;
  });

  describe('#Test notifyUser', () => {
    context('When event message is empty', () => {
      it('Should return', () => {
        expect(notification.notifyUser({})).to.be.an('undefined');
      });
    });

    describe('When event message is not empty', () => {
      let stubAddEventListener;

      beforeEach(() => {
        stubQuerySelector.withArgs('.notif-container-close');
        stubAddEventListener = sandBox.stub(window, 'addEventListener');
        sandBox.stub(notification, 'deleteNotification');
        sandBox.stub(notification, 'createNotificationBox');
        sandBox.stub(notification, 'resizeNotification');
      });

      afterEach(() => {
        if (sandBox.restore) {
          sandBox.restore();
        }
      });

      context('When timeout is specified', () => {
        it('Should test method notifyUser', () => {
          const event = {message: 'Une erreur est survenue, veuillez réessayer dans quelques instants', timeout: 1000};

          stubQuerySelector.returns({addEventListener: sandBox.spy()});

          notification.notifyUser(event);

          expect(notification).to.have.property('message').that.equal('Une erreur est survenue, veuillez réessayer dans quelques instants');
          expect(notification).to.have.property('timeout').that.equal(1000);
        });
      });

      context('When timeout not is specified', () => {
        it('Should test method notifyUser', () => {
          const event = {message: 'Une erreur est survenue, veuillez réessayer dans quelques instants'};

          stubQuerySelector.returns({addEventListener: sandBox.spy()});

          notification.notifyUser(event);

          expect(notification).to.have.property('message').that.equal('Une erreur est survenue, veuillez réessayer dans quelques instants');
          expect(notification).to.have.property('timeout').that.equal(5000);
        });
      });
    });
  });

  describe('#Test resizeNotification', () => {
    it('#Should test resizeNotification', () => {
      stubQuerySelector.withArgs('.notif-container').returns({clientHeight: 6, style: {}});
      stubQuerySelector.withArgs('.notif-container-content').returns({clientHeight: 2, style: {}});

      const element = document.querySelector('.notif-container-content');

      notification.resizeNotification();

      expect(element.style.position).to.equal('relative');
      expect(element.style.top).to.equal('2px');
    });
  });

  describe('#Test deleteNotification', () => {
    context('When document body contains notifContainer', () => {
      it('Should test deleteNotification ', () => {
        sandBox.stub(document.body, 'contains').returns(true);
        sandBox.stub(document.body, 'removeChild');

        notification.deleteNotification();

        expect(document.body.removeChild.calledOnce).to.be.true;
      });
    });

    context('When document body not contains notifContainer', () => {
      it('Should test deleteNotification', () => {
        sandBox.stub(document, 'body').returns(null);

        expect(notification.deleteNotification()).to.be.an('undefined');
      });
    });
  });

  describe('#Test createNotificationBox', () => {
    it('#Sould test createNotificationBox', () => {
      Notification.prototype.message = 'Une erreur est survenue, veuillez réessayer dans quelques instants';

      sandBox.stub(document.body, 'appendChild').returns(sandBox.spy());
      sandBox.stub(notification, 'deleteNotification');
      sandBox.stub(window, 'setTimeout').returns((callBack) => {
        callBack();
      });

      const html = `
                    <div class="notif-container-content">
                        <span class="notif-container-close">X</span>
                        <p>Une erreur est survenue, veuillez réessayer dans quelques instants</p>
                    </div>
                  `;

      notification.createNotificationBox();

      expect(notification.notifContainer.innerHTML).to.equal(html);
      expect(document.body.appendChild.calledOnce).to.be.true;
    });
  });
});

In this example all tests depend on:

stubQuerySelector = sandBox.stub(document, 'querySelector');
sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');

Also all tests use the same instance:

notification = new Notification();

As a result when we modify the stubs for some tests then the stubs will be applied to all tests. This is not a good practice.

Bests practices

In bests practices each test must have it own stubs and object instance specific to it. This greatly improves the readability.

const chai = require('chai'),
      sinon = require('sinon'),
      jsdom = require("jsdom"),
      { JSDOM } = jsdom;

const Notification = require('../notification');

describe('#Test Notification', () => {
  const sandBox = sinon.createSandbox(), 
        expect = chai.expect,
        dom = new JSDOM('');        

  window = dom.window;
  document = dom.window.document;

  afterEach(() => {
    if (sandBox.restore) {
      sandBox.restore();
    }
  });

  describe('#Test constructor', () => {
    before(() => {
      sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
    });

    it('Should test constructor property', () => {
      const notification = new Notification();

      expect(notification).to.have.property('timeout').to.equal(5000);
      expect(notification).to.have.property('notifContainer');
      expect(notification.addNotificationsEventListeners.calledOnce).to.be.true;
    });
  });

  describe('#Test notifyUser', () => {
    describe('When event message is empty', () => {
      before(() => {
        sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
      });

      it('Should return', () => {
        const notification = new Notification();

        expect(notification.notifyUser({})).to.be.an('undefined');
      });
    });

    describe('When event message is not empty', () => {
      let stubQuerySelector;

      beforeEach(() => {
        stubQuerySelector = sandBox.stub(document, 'querySelector').withArgs('.notif-container-close');
        sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
        sandBox.stub(Notification.prototype, 'resizeNotification');

        stubQuerySelector.returns({addEventListener: sandBox.spy()});
      });

      describe('When timeout is specified', () => {
        it('Should test method notifyUser', () => {
          const event = {message: 'Une erreur est survenue, veuillez réessayer dans quelques instants', timeout: 1000};
          const notification = new Notification();

          notification.notifyUser(event);

          expect(notification).to.have.property('message').that.equal('Une erreur est survenue, veuillez réessayer dans quelques instants');
          expect(notification).to.have.property('timeout').that.equal(1000);
        });
      });

      describe('When timeout not is specified', () => {
        it('Should test method notifyUser', () => {
          const event = {message: 'Une erreur est survenue, veuillez réessayer dans quelques instants'};
          const notification = new Notification();

          notification.notifyUser(event);

          expect(notification).to.have.property('message').that.equal('Une erreur est survenue, veuillez réessayer dans quelques instants');
          expect(notification).to.have.property('timeout').that.equal(5000);
        });
      });
    });
  });

  describe('#Test resizeNotification', () => {
    let stubQuerySelector;

    before(() => {
      stubQuerySelector = sandBox.stub(document, 'querySelector').withArgs('.notif-container-content');
      sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');

      stubQuerySelector.withArgs('.notif-container').returns({clientHeight: 6, style: {}});
      stubQuerySelector.withArgs('.notif-container-content').returns({clientHeight: 2, style: {}});
    });

    it('#Should test resizeNotification', () => {
      const notification = new Notification();

      notification.resizeNotification();

      expect(document.querySelector('.notif-container-content').style.position).to.equal('relative');
      expect(document.querySelector('.notif-container-content') .style.top).to.equal('2px');
    });
  });

  describe('#Test deleteNotification', () => {
    describe('When document body contains notifContainer', () => {
      before(() => {
        sandBox.stub(document.body, 'contains').returns(true);
        sandBox.stub(document.body, 'removeChild');
        sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
      });

      it('Should test deleteNotification ', () => {
        const notification = new Notification();

        notification.deleteNotification();

        expect(document.body.removeChild.calledOnce).to.be.true;
      });
    });

    describe('When document body not contains notifContainer', () => {
      before(() => {
        sandBox.stub(document, 'body').returns(null);
        sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');
      });

      it('Should test deleteNotification', () => {
        const notification = new Notification();

        expect(notification.deleteNotification()).to.be.an('undefined');
      });
    });
  });

  describe('#Test createNotificationBox', () => {
    before(() => {
      sandBox.stub(document.body, 'appendChild').returns(sandBox.spy());
      sandBox.stub(Notification.prototype, 'deleteNotification');
      sandBox.stub(window, 'setTimeout').returns((callBack) => {
        callBack();
      });
      sandBox.stub(Notification.prototype, 'addNotificationsEventListeners');

      Notification.prototype.message = 'Une erreur est survenue, veuillez réessayer dans quelques instants';
    });

    it('#Sould test createNotificationBox', () => {
      const html = `
                    <div class="notif-container-content">
                        <span class="notif-container-close">X</span>
                        <p>Une erreur est survenue, veuillez réessayer dans quelques instants</p>
                    </div>
                  `;

      const notification = new Notification();

      notification.createNotificationBox();

      expect(notification.notifContainer.innerHTML).to.equal(html);
      expect(document.body.appendChild.calledOnce).to.be.true;
    });
  });
});

Leave a Reply

Your email address will not be published. Required fields are marked *