UNPKG

chai-spies

Version:

Spies for the Chai assertion library.

763 lines (663 loc) 22 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, (function() {if (!global.chai) throw new Error("global.chai is not defined");global.chai.use(factory());})()); }(this, (function () { 'use strict'; /*! * chai-spies :: a chai plugin * Copyright (c) 2012 Jake Luer <jake@alogicalparadox.com> * MIT Licensed */ /*! * We are going to export a function that can be used through chai */ var spy = function (chai, _) { // Easy access var Assertion = chai.Assertion , flag = _.flag , i = _.inspect , STATE_KEY = typeof Symbol === 'undefined' ? '__state' : Symbol('state') , spyAmount = 0 , DEFAULT_SANDBOX = new Sandbox() , noop = function () {}; /** * # Sandbox constructor (function) * * Initialize new Sandbox instance * * @returns new sandbox * @api private */ function Sandbox() { this[STATE_KEY] = {}; } /** * # Sandbox.on (function) * * Wraps an object method into spy assigned to sandbox. All calls will * pass through to the original function. * * var spy = chai.spy.sandbox(); * var isArray = spy.on(Array, 'isArray'); * * const array = [] * const spy = chai.spy.sandbox(); * const [push, pop] = spy.on(array, ['push', 'pop']); * * spy.on(array, 'push', returns => 1) * * @param {Object} object * @param {String|String[]} method name or methods names to spy on * @param {Function} [fn] mock implementation * @returns created spy or created spies * @api public */ Sandbox.prototype.on = function (object, methodName, fn) { if (Array.isArray(methodName)) { return methodName.map(function (name) { return this.on(object, name, fn); }, this); } var isMethod = typeof object[methodName] === 'function'; if (methodName in object && !isMethod) { throw new Error([ 'Unable to spy property "', methodName, '". Only methods and non-existing properties can be spied.' ].join('')) } if (isMethod && object[methodName].__spy) { throw new Error('"' + methodName + '" is already a spy') } var method = chai.spy('object.' + methodName, fn || object[methodName]); var trackingId = ++spyAmount; this[STATE_KEY][trackingId] = method; method.__spy.tracked = { object: object , methodName: methodName , originalMethod: object[methodName] , isOwnMethod: Object.hasOwnProperty.call(object, methodName) }; object[methodName] = method; return method; }; /** * # Sandbox.restore (function) * * Restores previously wrapped object's method. * Restores all spied objects of a sandbox if called without parameters. * * var spy = chai.spy.sandbox(); * var object = spy.on(Array, 'isArray'); * spy.restore(Array, 'isArray'); // or spy.restore(); * * @param {Object} [object] * @param {String|String[]} [methods] method name or method names * @return {Sandbox} Sandbox instance * @api public */ Sandbox.prototype.restore = function (object, methods) { var exitAfter = 0, restored = 0; var sandbox = this; if (methods) { if (!Array.isArray(methods)) { methods = [methods]; } exitAfter = methods.length; } Object.keys(this[STATE_KEY]).some(function (spyId) { var spy = sandbox[STATE_KEY][spyId]; var tracked = spy.__spy.tracked; var shouldRestoreThisObject = !object || object === tracked.object; var shouldRestoreThisMethod = !methods || methods.indexOf(tracked.methodName) !== -1; // this wouldn't work if we could provide a method without providing an object if (!shouldRestoreThisObject || !shouldRestoreThisMethod) { return false; } delete sandbox[STATE_KEY][spyId]; sandbox.restoreTrackedObject(spy); if (++restored === exitAfter) { return true; } }); return this; }; /** * # Sandbox.restoreTrackedObject (function) * * Restores tracked object's method * * var spy = chai.spy.sandbox(); * var isArray = spy.on(Array, 'isArray'); * spy.restoreTrackedObject(isArray); * * @param {Spy} spy * @api private */ Sandbox.prototype.restoreTrackedObject = function (spy) { var tracked = spy.__spy.tracked; if (!tracked) { throw new Error('It is not possible to restore a non-tracked spy.') } if (tracked.isOwnMethod) { tracked.object[tracked.methodName] = tracked.originalMethod; } else { delete tracked.object[tracked.methodName]; } spy.__spy.tracked = null; }; /** * # chai.spy (function) * * Wraps a function in a proxy function. All calls will * pass through to the original function. * * function original() {} * var spy = chai.spy(original) * , e_spy = chai.spy(); * * @param {Function} function to spy on * @returns function to actually call * @api public */ chai.spy = function (name, fn) { if (typeof name === 'function') { fn = name; name = undefined; } fn = fn || noop; function makeProxy (length, fn) { switch (length) { case 0 : return function () { return fn.apply(this, arguments); }; case 1 : return function (a) { return fn.apply(this, arguments); }; case 2 : return function (a,b) { return fn.apply(this, arguments); }; case 3 : return function (a,b,c) { return fn.apply(this, arguments); }; case 4 : return function (a,b,c,d) { return fn.apply(this, arguments); }; case 5 : return function (a,b,c,d,e) { return fn.apply(this, arguments); }; case 6 : return function (a,b,c,d,e,f) { return fn.apply(this, arguments); }; case 7 : return function (a,b,c,d,e,f,g) { return fn.apply(this, arguments); }; case 8 : return function (a,b,c,d,e,f,g,h) { return fn.apply(this, arguments); }; case 9 : return function (a,b,c,d,e,f,g,h,i) { return fn.apply(this, arguments); }; default : return function (a,b,c,d,e,f,g,h,i,j) { return fn.apply(this, arguments); }; } } var proxy = makeProxy(fn.length, function () { var args = Array.prototype.slice.call(arguments); proxy.__spy.calls.push(args); proxy.__spy.called = true; return fn.apply(this, args); }); proxy.prototype = fn.prototype; proxy.toString = function toString() { var state = this.__spy; var l = state.calls.length; var s = "{ Spy"; if (state.name) s += " '" + state.name + "'"; if (l > 0) s += ", " + l + " call" + (l > 1 ? 's' : ''); s += " }"; return s + (fn !== noop ? "\n" + fn.toString() : ''); }; proxy.__spy = { calls: [] , called: false , name: name }; return proxy; }; /** * # chai.spy.sandbox (function) * * Creates sandbox which allow to restore spied objects with spy.on. * All calls will pass through to the original function. * * var spy = chai.spy.sandbox(); * var isArray = spy.on(Array, 'isArray'); * * @param {Object} object * @param {String} method name to spy on * @returns passed object * @api public */ chai.spy.sandbox = function () { return new Sandbox() }; /** * # chai.spy.on (function) * * The same as Sandbox.on. * Assignes newly created spy to DEFAULT sandbox * * var isArray = chai.spy.on(Array, 'isArray'); * * @see Sandbox.on * @api public */ chai.spy.on = function () { return DEFAULT_SANDBOX.on.apply(DEFAULT_SANDBOX, arguments) }; /** * # chai.spy.interface (function) * * Creates an object interface with spied methods. * * var events = chai.spy.interface('Events', ['trigger', 'on']); * * var array = chai.spy.interface({ * push(item) { * this.items = this.items || []; * return this.items.push(item); * } * }); * * @param {String|Object} name object or object name * @param {String[]} [methods] method names * @returns object with spied methods * @api public */ chai.spy.interface = function (name, methods) { var defs = {}; if (name && typeof name === 'object') { methods = Object.keys(name); defs = name; name = 'mock'; } return methods.reduce(function (object, methodName) { object[methodName] = chai.spy(name + '.' + methodName, defs[methodName]); return object; }, {}); }; /** * # chai.spy.restore (function) * * The same as Sandbox.restore. * Restores spy assigned to DEFAULT sandbox * * var array = [] * chai.spy.on(array, 'push'); * expect(array.push).to.be.spy // true * * chai.spy.restore() * expect(array.push).to.be.spy // false * * @see Sandbox.restore * @api public */ chai.spy.restore = function () { return DEFAULT_SANDBOX.restore.apply(DEFAULT_SANDBOX, arguments) }; /** * # chai.spy.returns (function) * * Creates a spy which returns static value. * * var method = chai.spy.returns(true); * * @param {*} value static value which is returned by spy * @returns new spy function which returns static value * @api public */ chai.spy.returns = function (value) { return chai.spy(function () { return value; }); }; /** * # spy * * Assert the the object in question is an chai.spy * wrapped function by looking for internals. * * expect(spy).to.be.spy; * spy.should.be.spy; * * @api public */ Assertion.addProperty('spy', function () { this.assert( 'undefined' !== typeof this._obj.__spy , 'expected ' + this._obj + ' to be a spy' , 'expected ' + this._obj + ' to not be a spy'); return this; }); /** * # .called * * Assert that a spy has been called. Does not negate to allow for * pass through language. * * @api public */ function assertCalled (n) { new Assertion(this._obj).to.be.spy; var spy = this._obj.__spy; if (n) { this.assert( spy.calls.length === n , 'expected ' + this._obj + ' to have been called #{exp} but got #{act}' , 'expected ' + this._obj + ' to have not been called #{exp}' , n , spy.calls.length ); } else { this.assert( spy.called === true , 'expected ' + this._obj + ' to have been called' , 'expected ' + this._obj + ' to not have been called' ); } } function assertCalledChain () { new Assertion(this._obj).to.be.spy; } Assertion.addChainableMethod('called', assertCalled, assertCalledChain); /** * # once * * Assert that a spy has been called exactly once * * @api public */ Assertion.addProperty('once', function () { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length === 1 , 'expected ' + this._obj + ' to have been called once but got #{act}' , 'expected ' + this._obj + ' to not have been called once' , 1 , this._obj.__spy.calls.length ); }); /** * # twice * * Assert that a spy has been called exactly twice. * * @api public */ Assertion.addProperty('twice', function () { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length === 2 , 'expected ' + this._obj + ' to have been called twice but got #{act}' , 'expected ' + this._obj + ' to not have been called twice' , 2 , this._obj.__spy.calls.length ); }); /** * # nth call (spy, n, arguments) * * Asserts that the nth call of the spy has been called with * */ function nthCallWith(spy, n, expArgs) { if (spy.calls.length < n) return false; var actArgs = spy.calls[n].slice() , passed = 0; expArgs.forEach(function (expArg) { for (var i = 0; i < actArgs.length; i++) { if (_.eql(actArgs[i], expArg)) { passed++; actArgs.splice(i, 1); break; } } }); return passed === expArgs.length } function numberOfCallsWith(spy, expArgs) { var found = 0 , calls = spy.calls; for (var i = 0; i < calls.length; i++) { if (nthCallWith(spy, i, expArgs)) { found++; } } return found; } Assertion.addProperty('first', function () { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy nth call with', 1); } }); Assertion.addProperty('second', function () { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy nth call with', 2); } }); Assertion.addProperty('third', function () { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy nth call with', 3); } }); Assertion.addProperty('on'); Assertion.addChainableMethod('nth', function (n) { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy nth call with', n); } }); function generateOrdinalNumber(n) { if (n === 1) return 'first'; if (n === 2) return 'second'; if (n === 3) return 'third'; return n + 'th'; } /** * ### .with * */ function assertWith() { new Assertion(this._obj).to.be.spy; var expArgs = [].slice.call(arguments, 0) , spy = this._obj.__spy , calls = spy.calls , always = _.flag(this, 'spy always') , nthCall = _.flag(this, 'spy nth call with'); if (always) { var passed = numberOfCallsWith(spy, expArgs); this.assert( passed === calls.length , 'expected ' + this._obj + ' to have been always called with #{exp} but got ' + passed + ' out of ' + calls.length , 'expected ' + this._obj + ' to have not always been called with #{exp}' , expArgs ); } else if (nthCall) { var ordinalNumber = generateOrdinalNumber(nthCall), actArgs = calls[nthCall - 1]; new Assertion(this._obj).to.be.have.been.called.min(nthCall); this.assert( nthCallWith(spy, nthCall - 1, expArgs) , 'expected ' + this._obj + ' to have been called at the ' + ordinalNumber + ' time with #{exp} but got #{act}' , 'expected ' + this._obj + ' to have not been called at the ' + ordinalNumber + ' time with #{exp}' , expArgs , actArgs ); } else { var passed = numberOfCallsWith(spy, expArgs); this.assert( passed > 0 , 'expected ' + this._obj + ' to have been called with #{exp}' , 'expected ' + this._obj + ' to have not been called with #{exp} but got ' + passed + ' times' , expArgs ); } } function assertWithChain () { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy with', true); } } Assertion.addChainableMethod('with', assertWith, assertWithChain); Assertion.addProperty('always', function () { if ('undefined' !== this._obj.__spy) { _.flag(this, 'spy always', true); } }); /** * # exactly (n) * * Assert that a spy has been called exactly `n` times. * * @param {Number} n times * @api public */ Assertion.addMethod('exactly', function () { new Assertion(this._obj).to.be.spy; var always = _.flag(this, 'spy always') , _with = _.flag(this, 'spy with') , args = [].slice.call(arguments, 0) , calls = this._obj.__spy.calls , nthCall = _.flag(this, 'spy nth call with') , passed; if (always && _with) { passed = 0; calls.forEach(function (call) { if (call.length !== args.length) return; if (_.eql(call, args)) passed++; }); this.assert( passed === calls.length , 'expected ' + this._obj + ' to have been always called with exactly #{exp} but got ' + passed + ' out of ' + calls.length , 'expected ' + this._obj + ' to have not always been called with exactly #{exp}' , args ); } else if(_with && nthCall) { var ordinalNumber = generateOrdinalNumber(nthCall), actArgs = calls[nthCall - 1]; new Assertion(this._obj).to.be.have.been.called.min(nthCall); this.assert( _.eql(actArgs, args) , 'expected ' + this._obj + ' to have been called at the ' + ordinalNumber + ' time with exactly #{exp} but got #{act}' , 'expected ' + this._obj + ' to have not been called at the ' + ordinalNumber + ' time with exactly #{exp}' , args , actArgs ); } else if (_with) { passed = 0; calls.forEach(function (call) { if (call.length !== args.length) return; if (_.eql(call, args)) passed++; }); this.assert( passed > 0 , 'expected ' + this._obj + ' to have been called with exactly #{exp}' , 'expected ' + this._obj + ' to not have been called with exactly #{exp} but got ' + passed + ' times' , args ); } else { this.assert( this._obj.__spy.calls.length === args[0] , 'expected ' + this._obj + ' to have been called #{exp} times but got #{act}' , 'expected ' + this._obj + ' to not have been called #{exp} times' , args[0] , this._obj.__spy.calls.length ); } }); /** * # gt (n) * * Assert that a spy has been called more than `n` times. * * @param {Number} n times * @api public */ function above (_super) { return function (n) { if ('undefined' !== typeof this._obj.__spy) { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length > n , 'expected ' + this._obj + ' to have been called more than #{exp} times but got #{act}' , 'expected ' + this._obj + ' to have been called at most #{exp} times but got #{act}' , n , this._obj.__spy.calls.length ); } else { _super.apply(this, arguments); } } } Assertion.overwriteMethod('above', above); Assertion.overwriteMethod('gt', above); /** * # lt (n) * * Assert that a spy has been called less than `n` times. * * @param {Number} n times * @api public */ function below (_super) { return function (n) { if ('undefined' !== typeof this._obj.__spy) { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length < n , 'expected ' + this._obj + ' to have been called fewer than #{exp} times but got #{act}' , 'expected ' + this._obj + ' to have been called at least #{exp} times but got #{act}' , n , this._obj.__spy.calls.length ); } else { _super.apply(this, arguments); } } } Assertion.overwriteMethod('below', below); Assertion.overwriteMethod('lt', below); /** * # min (n) * * Assert that a spy has been called `n` or more times. * * @param {Number} n times * @api public */ function min (_super) { return function (n) { if ('undefined' !== typeof this._obj.__spy) { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length >= n , 'expected ' + this._obj + ' to have been called at least #{exp} times but got #{act}' , 'expected ' + this._obj + ' to have been called fewer than #{exp} times but got #{act}' , n , this._obj.__spy.calls.length ); } else { _super.apply(this, arguments); } } } Assertion.overwriteMethod('min', min); Assertion.overwriteMethod('least', min); /** * # max (n) * * Assert that a spy has been called `n` or fewer times. * * @param {Number} n times * @api public */ function max (_super) { return function (n) { if ('undefined' !== typeof this._obj.__spy) { new Assertion(this._obj).to.be.spy; this.assert( this._obj.__spy.calls.length <= n , 'expected ' + this._obj + ' to have been called at most #{exp} times but got #{act}' , 'expected ' + this._obj + ' to have been called more than #{exp} times but got #{act}' , n , this._obj.__spy.calls.length ); } else { _super.apply(this, arguments); } } } Assertion.overwriteMethod('max', max); Assertion.overwriteMethod('most', max); }; return spy; })));