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