From dcec1f0f0442beef02b152162f8667683b7902f7 Mon Sep 17 00:00:00 2001 From: Nick Carenza Date: Mon, 17 Apr 2017 16:31:01 -0700 Subject: [PATCH] chai.spy.callsBackWith implements #65 --- README.md | 4 + chai-spies.js | 309 +++++++++++++++++++++++++++++++++++++++++--------- lib/spy.js | 24 ++++ test/spies.js | 29 +++++ 4 files changed, 311 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2afcf48..6dadadd 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ object.push(5); // or you create spy which returns static value var spy = chai.spy.returns(true); +// or you create spy which calls back with some values +var spy = chai.spy.callsBackWith(new Error('foo')); +var spy = chai.spy.callsBackWith(null, {id: 12}); + spy(); // true ``` diff --git a/chai-spies.js b/chai-spies.js index 2405394..bea3778 100644 --- a/chai-spies.js +++ b/chai-spies.js @@ -29,6 +29,151 @@ var Assertion = chai.Assertion , flag = _.flag , i = _.inspect + , STATE_KEY = typeof Symbol === 'undefined' ? '__state' : Symbol('state') + , spyAmount = 0 + , DEFAULT_SANDBOX = new Sandbox() + + /** + * # 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']); + * + * @param {Object} object + * @param {String|String[]} method name or methods names to spy on + * @returns created spy or created spies + * @api public + */ + + Sandbox.prototype.on = function (object, methodName) { + if (Array.isArray(methodName)) { + return methodName.map(function (name) { + return this.on(object, name); + }, 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, object[methodName]); + var trackingId = ++spyAmount + + this[STATE_KEY][trackingId] = method; + method.__spy.tracked = { + object: object + , methodName: methodName + , originalMethod: object[methodName] + , isOwnMethod: object.hasOwnProperty(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 hasFilter = Boolean(object && methods); + var sandbox = this; + + if (methods && !Array.isArray(methods)) { + methods = [methods] + } + + Object.keys(this[STATE_KEY]).some(function (spyId) { + var spy = sandbox[STATE_KEY][spyId]; + var tracked = spy.__spy.tracked; + var isObjectSpied = !object || object === tracked.object; + var isMethodSpied = !methods || methods.indexOf(tracked.methodName) !== -1; + + delete sandbox[STATE_KEY][spyId]; + + if (!isObjectSpied && !isMethodSpied) { + return false; + } + + sandbox.restoreTrackedObject(spy); + + if (hasFilter) { + 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) @@ -78,10 +223,11 @@ proxy.prototype = fn.prototype; proxy.toString = function toString() { - var l = this.__spy.calls.length; + var state = this.__spy; + var l = state.calls.length; var s = "{ Spy"; - if (this.__spy.name) - s += " '" + this.__spy.name + "'"; + if (state.name) + s += " '" + state.name + "'"; if (l > 0) s += ", " + l + " call" + (l > 1 ? 's' : ''); s += " }"; @@ -91,7 +237,7 @@ /** * # proxy.reset (function) * - * Resets __spy object parameters for instantiation and reuse + * Resets spy's state object parameters for instantiation and reuse * @returns proxy spy object */ proxy.reset = function() { @@ -107,53 +253,67 @@ } /** - * # chai.spy.on (function) + * # chai.spy.sandbox (function) * - * Wraps an object method into spy. All calls will - * pass through to the original 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.on(Array, 'isArray'); + * var spy = chai.spy.sandbox(); + * var isArray = spy.on(Array, 'isArray'); * * @param {Object} object - * @param {...String} method names to spy on + * @param {String} method name to spy on * @returns passed object * @api public */ - chai.spy.on = function (object) { - var methodNames = Array.prototype.slice.call(arguments, 1); + chai.spy.sandbox = function () { + return new Sandbox() + }; - methodNames.forEach(function(methodName) { - object[methodName] = chai.spy(object[methodName]); - }); + /** + * # 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 + */ - return object; + chai.spy.on = function () { + return DEFAULT_SANDBOX.on.apply(DEFAULT_SANDBOX, arguments) }; /** - * # chai.spy.object (function) + * # chai.spy.interface (function) + * + * Creates an object interface with spied methods. * - * Creates an object with spied methods. + * var events = chai.spy.interface('Events', ['trigger', 'on']); * - * var object = chai.spy.object('Array', [ 'push', 'pop' ]); + * var array = chai.spy.interface({ + * push(item) { + * this.items = this.items || []; + * return this.items.push(item); + * } + * }); * - * @param {String} [name] object name - * @param {String[]|Object} method names or method definitions + * @param {String|Object} name object or object name + * @param {String[]} [methods] method names * @returns object with spied methods * @api public */ - chai.spy.object = function (name, methods) { + chai.spy.interface = function (name, methods) { var defs = {}; if (name && typeof name === 'object') { - methods = name; - name = 'object'; - } - - if (methods && !Array.isArray(methods)) { - defs = methods; - methods = Object.keys(methods); + methods = Object.keys(name); + defs = name; + name = 'mock'; } return methods.reduce(function (object, methodName) { @@ -162,6 +322,27 @@ }, {}); }; + /** + * # 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) * @@ -180,6 +361,30 @@ }); }; + /** + * # chai.spy.callsBackWith (function) + * + * Creates a spy which automatically calls any passed callback function with the provided values. + * + * var method = chai.spy.callsBackWith(new Error('foo')); + * var method = chai.spy.callsBackWith(null, {id:12}); + * + * @param {*} value static value which is returned by spy + * @returns new spy function which calls the + * @api public + */ + + chai.spy.callsBackWith = function () { + var args = arguments; + return chai.spy(function(){ + callbackFn = arguments[arguments.length-1]; + chai.assert( + typeof callbackFn === 'function' + , 'expected ' + callbackFn + ' to be a callback function'); + callbackFn.apply(null, args); + }); + } + /** * # spy * @@ -280,46 +485,40 @@ function assertWith () { new Assertion(this._obj).to.be.spy; - var args = [].slice.call(arguments, 0) + var expArgs = [].slice.call(arguments, 0) , calls = this._obj.__spy.calls , always = _.flag(this, 'spy always') - , passed; - - if (always) { - passed = 0 - calls.forEach(function (call) { - var found = 0; - args.forEach(function (arg) { - for (var i = 0; i < call.length; i++) { - if (_.eql(call[i], arg)) found++; + , passed = 0; + + calls.forEach(function (call) { + var actArgs = call.slice() + , found = 0; + + expArgs.forEach(function (expArg) { + for (var i = 0; i < actArgs.length; i++) { + if (_.eql(actArgs[i], expArg)) { + found++; + actArgs.splice(i, 1); + break; } - }); - if (found === args.length) passed++; + } }); + if (found === expArgs.length) passed++; + }); + if (always) { this.assert( passed === calls.length , 'expected ' + this._obj + ' to have been always called with #{exp} but got ' + passed + ' out of ' + calls.length - , 'expected ' + this._his + ' to have not always been called with #{exp}' - , args + , 'expected ' + this._obj + ' to have not always been called with #{exp}' + , expArgs ); } else { - passed = 0; - calls.forEach(function (call) { - var found = 0; - args.forEach(function (arg) { - for (var i = 0; i < call.length; i++) { - if (_.eql(call[i], arg)) found++; - } - }); - if (found === args.length) passed++; - }); - this.assert( passed > 0 , 'expected ' + this._obj + ' to have been called with #{exp}' - , 'expected ' + this._his + ' to have not been called with #{exp} but got ' + passed + ' times' - , args + , 'expected ' + this._obj + ' to have not been called with #{exp} but got ' + passed + ' times' + , expArgs ); } } diff --git a/lib/spy.js b/lib/spy.js index 4b45992..6cd8dc0 100644 --- a/lib/spy.js +++ b/lib/spy.js @@ -345,6 +345,30 @@ module.exports = function (chai, _) { }); }; + /** + * # chai.spy.callsBackWith (function) + * + * Creates a spy which automatically calls any passed callback function with the provided values. + * + * var method = chai.spy.callsBackWith(new Error('foo')); + * var method = chai.spy.callsBackWith(null, {id:12}); + * + * @param {*} value static value which is returned by spy + * @returns new spy function which calls the + * @api public + */ + + chai.spy.callsBackWith = function () { + var args = arguments; + return chai.spy(function(){ + callbackFn = arguments[arguments.length-1]; + chai.assert( + typeof callbackFn === 'function' + , 'expected ' + callbackFn + ' to be a callback function'); + callbackFn.apply(null, args); + }); + } + /** * # spy * diff --git a/test/spies.js b/test/spies.js index c02e51d..1da41ca 100644 --- a/test/spies.js +++ b/test/spies.js @@ -216,6 +216,35 @@ describe('Chai Spies', function () { spy().should.equal(value); }); + it('should create spy which calls back with static values', function() { + var value1 = 7; + var value2 = true; + var value3 = {id:4}; + var spy = chai.spy.callsBackWith(value1, value2, value3); + + var callback = function(arg1, arg2, arg3) { + arg1.should.equal(value1); + arg2.should.equal(value2); + arg3.should.equal(value3); + }; + + spy.should.be.a.spy; + spy(123, false, callback); + }); + + it('should know if final argument is a function', function() { + var value = 'foo'; + var spy = chai.spy.callsBackWith(value); + + spy.should.be.a.spy; + (function(){ + spy(3); + }).should.throw(chai.AssertionError); + (function(){ + spy(); + }).should.throw(chai.AssertionError); + }); + describe('.with', function () { it('should not interfere chai with' ,function () { (1).should.be.with.a('number');