Javascript testing Cookbook

by Artem Miroshnyk

What to use?

What to use? ;)

Code style difference

const framework1 = {
  name: 'name',
  githubStars: 1000,
};
const framework2 = {
  name: 'name',
  githubStars: 1000,
};

// Jasmine and Jest
describe('Difference between js test frameworks', () => {
  it('have all the same properties', () => {
    expect(framework1).toEqual(framework2);
  });
  it('are not the exact same can', () => {
    expect(framework1).not.toBe(framework2);
  });
});

// Mocha with Chai
describe('Difference between js test frameworks', () => {
  it('have all the same properties', () => {
    expect(framework1).to.eqls(framework2); // Or .to.deep.equal()
  });
  it('are not the exact same can', () => {
    expect(framework1).to.not.equal(framework2);
  });
});

Doubles difference

// Mocha+Chai+Sinon
const fn = sinon.spy();
const result = fn(1, 2);
expect(fn).to.have.been.calledWith(1, 2);

// Jest
const fn = jest.fn();
fn(1, 2);
expect(fn.mock.calls).toEqual([1, 2]);

// Jasmine
const fn = jasmine.createSpy(); 
fn(1, 2);
expect(fn.calls.last().args).toEqual([1, 2]);

What and how to test?

  Drupal.t = function (str, args, options) {
    options = options || {};
    options.context = options.context || '';

    // Fetch the localized version of the string.
    if (typeof drupalTranslations !== 'undefined' && drupalTranslations.strings
        && drupalTranslations.strings[options.context]
        && drupalTranslations.strings[options.context][str]
    ) {
      str = drupalTranslations.strings[options.context][str];
    }

    if (args) {
      str = Drupal.formatString(str, args);
    }
    return str;
  };

External global variables, external function, requires mocking data and functions

Drupal.behaviors.blockSettingsSummary = {
    attach: function () {
      if (typeof $.fn.drupalSetSummary === 'undefined') {
        return;
      }
      function checkboxesSummary(context) {
        var vals = [];
        var $checkboxes = $(context).find('input[type="checkbox"]:checked + label');
        var il = $checkboxes.length;
        for (var i = 0; i < il; i++) {
          vals.push($($checkboxes[i]).html());
        }
        if (!vals.length) {
          vals.push(Drupal.t('Not restricted'));
        }
        return vals.join(', ');
      }

      $('[data-drupal-selector="edit-visibility-node-type"], [data-drupal-selector="edit-visibility-language"], [data-drupal-selector="edit-visibility-user-role"]').drupalSetSummary(checkboxesSummary);

      $('[data-drupal-selector="edit-visibility-request-path"]').drupalSetSummary(function (context) {
        var $pages = $(context).find('textarea[name="visibility[request_path][pages]"]');
        if (!$pages.val()) {
          return Drupal.t('Not restricted');
        }
        else {
          return Drupal.t('Restricted to certain pages');
        }
      });
    }
  };

External global variables, external function, requires mocking data and functions, jQuery, HTML, jQuery plugins

import fetch from 'whatwg-fetch';

function checkStatus(response) {
  if (response.status >= 200 && response.status < 300) {
    return response
  } else {
    var error = new Error(response.statusText)
    error.response = response
    throw error
  }
}

fetch('/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'Hubot',
    login: 'hubot',
  }))
  .then(checkStatus)
  .then(response => response.json())
  .then(function(data) {
    console.log('request succeeded with JSON response', data)
  }).catch(function(error) {
    console.log('request failed', error)
  })

ES6, Networking, Promises, mocking, imports

API

describe('calculator', () => {
  describe('add()', () => {
    it('should add 2 numbers together', () => {
      // assertions here
    });

    it('should more then 2 numbers together', () => {
      // assertions here
    });
  });

  describe('multiply()', () => {
    it('should multiply 2 numbers together', () => {
      // assertions here
    })
  });
});

Test suit and test case

describe('hooks', function() {

  before(function() {
    window.testProperty = {};
  });

  after(function() {
    delete window.testProperty;
  });

  beforeEach(function() {
    window.testProperty = {value: 'value'};
  });

  afterEach(function() {
    // runs after each test case.
  });

});

Hooks

import chai, {expect} from 'chai';

describe('hooks', function () {
  const delayedIncrement = (counter) => 
     new Promise(resolve => setTimeout(() => resolve(++counter), 3000));

  it('supports timers', (done) => {
    let counter = 0;
    const promise = delayedIncrement(counter);
    expect(counter).to.eqls(0);
    promise.then(counter => {
      expect(counter).to.eqls(3);
      done();
    });
  });
});

Naive async

import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';

chai.use(chaiAsPromised);

describe('hooks', function () {
  const delayedIncrement = (counter) =>
    new Promise(resolve => setTimeout(() => resolve(++counter), 3000));

  it('supports timers', () => {
    let counter = 0;
    const promise = delayedIncrement(counter);
    expect(counter).to.eqls(0);
    expect(promise).to.eventually.eqls(1);
  });
});

Promises tests with chai-as-promised

import chai, {expect} from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';

chai.should();
chai.use(sinonChai);

describe('spy', function () {
  it('supports spies', () => {
    const fn = (a, b) => a + b + (a * b) + (a / b);

    const spy = sinon.spy(fn);
    spy.should.not.have.been.called;

    expect(spy(2, 1)).to.eqls(7);
    spy.should.have.been.calledOnce;
    expect(spy).to.have.been.calledWith(2, 1);
    spy(3, 4);
    expect(spy.getCall(1).args).to.eqls([3, 4]);
    expect(spy.getCall(1).exception).to.eqls(undefined);
    expect(spy.getCall(1).returnValue).to.eqls(19.75);
  });
});

Spy on function

Do not change function behaviour, just watches and logs everything

import jQuery from 'jquery';

describe('spy on method', function () {
  const apiRequest = url => jQuery.ajax(url);

  it('supports spies on methods', () => {
    sinon.spy(jQuery, 'ajax');
    
    // jQuery still will do ajax request
    apiRequest('/some/url');
    expect(jQuery.ajax).to.have.been.calledWith('/some/url');
  });
});

Spy on methods

import jQuery from 'jquery';

describe('spy on method', function () {
  const apiRequest = url => jQuery.ajax(url);

  it('supports spies on methods', () => {
    sinon.stub(jQuery, 'ajax');

    jQuery.ajax.withArgs('/path1').returns(1);
    jQuery.ajax.withArgs('/path2').throws("TypeError");
    

    // There won't be any real ajax request.
    expect(apiRequest('/path1')).to.eqls(1);
    expect(jQuery.ajax).to.have.been.calledWith('/path2');
    
    expect(apiRequest('/path2')).to.throw(new TypeError);

    expect(apiRequest('/path3')).to.eqls(undefined);
  });
});

Sinon stubs

Stubs are like spies, except in that they replace the target function. They can also contain custom behavior, such as returning values or throwing exceptions.

import jQuery from 'jquery';

describe('spy on method', function () {
  const apiRequest = url => jQuery.ajax(url);

  it('supports spies on methods', () => {
    const mock = sinon.mock(jQuery);
    mock.expects("ajax").once()
      .withExactArgs('/path, {method: 'POST'});
    
    apiRequest('/path')
    
    // It will fail because it misses second argument.
    mock.verify();
  });
});

Sinon mocks

Mocks are fake methods (like spies) with pre-programmed behavior (like stubs) as well as pre-programmed expectations.

jQuery tests

The main trick here is


document.body.innerHTML = '<html></html>';
import chai, {expect} from 'chai';
import $ from 'jquery';

describe('spy', function () {
  it('supports jquery', () => {
    document.body.innerHTML =
      '<div>' +
      '<span id="username">Username</span>' +
      '<button id="button">Login</button>' +
      '</div>';
    $('#username').after('<span id="password">Password</span>');
    expect(document.body.innerHTML).to.eqls('<div>' +
      '<span id="username">Username</span>' +
      '<span id="password">Password</span>' +
      '<button id="button">Login</button>' +
      '</div>');
  });
});
sendPing: function () {
  if (this.timer) {
    clearTimeout(this.timer);
  }
  if (this.uri) {
    var pb = this;
    // When doing a post request, you need non-null data. Otherwise a
    // HTTP 411 or HTTP 406 (with Apache mod_security) error may result.
    var uri = this.uri;
    if (uri.indexOf('?') === -1) {
      uri += '?';
    }
    else {
      uri += '&';
    }
    uri += '_format=json';
    $.ajax({
      type: this.method,
      url: uri,
      data: '',
      dataType: 'json',
      success: function (progress) {
        // Display errors.
        if (progress.status === 0) {
          pb.displayError(progress.data);
          return;
        }
        // Update display.
        pb.setProgress(progress.percentage, progress.message, progress.label);
        // Schedule next timer.
        pb.timer = setTimeout(function () { pb.sendPing(); }, pb.delay);
      },
      error: function (xmlhttp) {
        var e = new Drupal.AjaxError(xmlhttp, pb.uri);
        pb.displayError('<pre>' + e.message + '</pre>');
      }
    });
  }
},
describe("Drupal get progress test", function () {
    let server;
    const fakeData = {
      status: 1,
      data: "",
      message: "Everything is fine so far",
      label: "Label string",
      percentage: 15
    };
    const url = '/progress-bar';

    before(function () {
        server = sinon.fakeServer.create();
        server.respondWith(
            "GET", 
            url,
            [200, { "Content-Type": "application/json" }, JSON.stringify(fakeData)]
        );
    });
    after(function () { server.restore(); });
})

Step 1

Prepare sinon fakeServer

describe("Drupal get progress test", function () {
  it("should set progress and timer", () => {
    const progressBar = new Drupal.ProgressBar(
      'uniqueId',
      () => (),
      'GET',
      () => ()
    );
    const stub = sinon.stub(progressBar.prototype, 'setProgress');
    
    progreesBar.sendPending();
    expect(stub.getCall(0).args).to.eqls([
      15,
      'Everything is fine so far',
      'Label string'
    ]);
  });
});

Step 2

Test itself :)

$ mocha --compilers js:babel/register

ES6

or bootstrap.js file and

require('babel-register')();
const Ticket = ({ ticket, inputChange }) => {
  const onChange = function (name, value) {
    value = typeof(value) == 'boolean' ? (value ? 1 : 0) : value;
    inputChange(name, value);
  };

  let input;
  if (ticket.maxUnits === 1) {
    input = (<CheckboxInput
      name={ticket.serviceId}
      onChange={onChange}
      value={!!ticket.unitCount}
    />);
  }
  else {
    input = (<NumberInput
      loadingWrapClass="loading-wrap-service-input"
      name={ticket.serviceId}
      onChange={onChange}
      value={ticket.unitCount}
      max={ticket.maxUnits}
    />);
  }

  return (
    <ul className="service">
      <li className="service__name">{ticket.name}</li>
      <li className="service__tooltip">
        <Tooltip><span>{ticket.description}</span></Tooltip>
      </li>
      <li className="service__input">{input}</li>
    </ul>
  );
};

JSX (React) via Enzyme

import React from 'react';
import {shallow, mount} from 'enzyme';
import chai, {expect} from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import mergeOptions from 'merge-options';
import NumberInput from '../../components/NumberInput';
import CheckboxInput from '../../components/formComponents/CheckboxInput';
import Ticket from '../../components/Ticket';
import Tooltip from '../../components/Tooltip';

chai.use(sinonChai);

describe('<Ticket />', () => {
  const getProps = () => ({
    ticket: {
      serviceId: "qwer1234",
      priceUnitType: "PER_STAY",
      maxUnits: 1,
      unitCount: 0,
      name: "Child chair",
      description: "High chair",
      unitPrice: {
        amount: 100.5,
        currencyCode: "EUR"
      },
      minimumPrice: {
        amount: 604.8,
        currencyCode: "EUR"
      },
    },
    inputChange: sinon.spy()
  });
});

Prepare test suite

describe('<Ticket />', () => {
  it('Renders 4 li with appropriate content', () => {
    const props = getProps();
    let wrapper = shallow(<Ticket {...props} />);
    expect(wrapper.find('li')).to.have.length(4);
    expect(wrapper.find('.service__name').text()).to.equal(props.ticket.name);
    expect(wrapper.find('.service__tooltip').containsMatchingElement(<Tooltip>
      <span>{props.ticket.description}</span>
    </Tooltip>)).to.equal(true);
    expect(wrapper.find('.service__input').find(CheckboxInput)).to.have.length(1);
  });
});

Test markup parts

describe('<Ticket />', () => {
  it('should handle onChange', () => {
    const props = getProps();
    const wrapper = mount(<Ticket {...props} />);
    props.inputChange.should.not.have.been.called;
    wrapper.find('input').simulate('change', { target: { checked: true }});
    props.inputChange.should.have.been.calledOnce;
    expect(props.inputChange).to.have.been.calledWith(props.ticket.serviceId, 1);
  });
});

Test change handler

describe('<Ticket />', () => {
  it('should render proper input', () => {
    const props = getProps();
    let wrapper = shallow(<Ticket {...props} />);
    expect(wrapper.find(CheckboxInput).length).to.eqls(1);
    expect(wrapper.find(CheckboxInput).prop('name')).to.eqls(props.ticket.serviceId);
    expect(wrapper.find(CheckboxInput).prop('value')).to.eqls(!!props.ticket.unitCount);

    props.ticket.maxUnits = 10;
    wrapper = shallow(<Ticket {...props} />);
    expect(wrapper.find(CheckboxInput).length).to.eqls(0);
    expect(wrapper.find(LoadableNumberInput).length).to.eqls(1);
    expect(wrapper.find(LoadableNumberInput).prop('name')).to.eqls(props.ticket.serviceId);
    expect(wrapper.find(LoadableNumberInput).prop('value')).to.eqls(props.ticket.unitCount);
    expect(wrapper.find(LoadableNumberInput).prop('max')).to.eqls(props.ticket.maxUnits);
  });
});

Test internal component's logic

Questions?

BTW, Novasol is hiring Drupal 8 developers, Front-end developer and Java developers

deck

By Artyom Miroshnik

deck

  • 804