diff --git a/lib/spy.js b/lib/spy.js index 3bd26b1..4b45992 100644 --- a/lib/spy.js +++ b/lib/spy.js @@ -13,6 +13,151 @@ module.exports = function (chai, _) { 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) @@ -62,10 +207,11 @@ module.exports = function (chai, _) { 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 += " }"; @@ -75,7 +221,7 @@ module.exports = function (chai, _) { /** * # 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() { @@ -91,53 +237,67 @@ module.exports = function (chai, _) { } /** - * # 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) { @@ -146,6 +306,27 @@ module.exports = function (chai, _) { }, {}); }; + /** + * # 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) * diff --git a/test/spies.js b/test/spies.js index c9de8a2..c02e51d 100644 --- a/test/spies.js +++ b/test/spies.js @@ -208,14 +208,6 @@ describe('Chai Spies', function () { spyClean.should.have.length(0); }); - it('should spy specified object method', function () { - var array = chai.spy.on([], 'push'); - array.push(1, 2); - - array.push.should.be.a.spy; - array.should.have.length(2); - }); - it('should create spy which returns static value', function() { var value = {}; var spy = chai.spy.returns(value); @@ -224,13 +216,6 @@ describe('Chai Spies', function () { spy().should.equal(value); }); - it('should spy multiple object methods passed as array', function () { - var array = chai.spy.on([], 'push', 'pop'); - - array.push.should.be.a.spy; - array.pop.should.be.a.spy; - }); - describe('.with', function () { it('should not interfere chai with' ,function () { (1).should.be.with.a('number'); @@ -416,44 +401,90 @@ describe('Chai Spies', function () { }); }); - describe('spy object', function () { - it('should create a spy object with specified method names', function () { - var object = chai.spy.object('array', [ 'push', 'pop' ]); + describe('spy on', function () { + var object; + + beforeEach(function () { + object = [] + }); + + it('should spy specified object method', function () { + chai.spy.on(object, 'push'); + object.push(1, 2); object.push.should.be.a.spy; - object.pop.should.be.a.spy; + object.should.have.length(2); }); - it('should create an anonymous spy object', function () { - var object = chai.spy.object([ 'push' ]); + it('should spy multiple object methods', function () { + chai.spy.on(object, ['push', 'pop']); object.push.should.be.a.spy; + object.pop.should.be.a.spy; }); - it('should create a spy object with specified method definitions', function () { - var object = chai.spy.object('array', { - push: function () { - return 'push'; + it('should allow to create spy for non-existing property', function () { + chai.spy.on(object, 'nonExistingProperty'); + + object.nonExistingProperty.should.be.a.spy; + }); + + it('should throw if non function property is passed', function () { + (function () { + chai.spy.on(object, 'length'); + }).should.throw(Error); + }); + + it('should throw if method is already a spy', function () { + object.push = chai.spy(); + + (function () { + chai.spy.on(object, 'push'); + }).should.throw(Error) + }); + }); + + describe('spy interface', function () { + + it('should create a spy object with specified method names', function () { + var array = chai.spy.interface('array', ['push', 'pop']); + + array.push.should.be.a.spy; + array.pop.should.be.a.spy; + }); + + it('should wrap each method in spy', function () { + var array = []; + var object = chai.spy.interface({ + push: function() { + return array.push.apply(array, arguments); } }); + object.push(1, 2, 3); + object.push.should.be.a.spy; - object.push().should.equal('push'); + array.should.have.length(3); }); - it('should create an anonymous spy object with methods implementation', function () { - var object = chai.spy.object({ + it('should return value from spied method', function () { + var object = chai.spy.interface({ push: function () { - return 'push' + return 'push'; } }); - object.push.should.be.a.spy; object.push().should.equal('push'); }); + + it('should create a plain object', function () { + var object = chai.spy.interface('Object', ['method']); + + object.should.be.an('object'); + }); }); - describe('reset method', function() { + describe('reset method', function () { it('should reset spy object values to defaults when called', function() { var name = 'proxy'; var spy = chai.spy(name); @@ -482,4 +513,39 @@ describe('Chai Spies', function () { spy.__spy.name.should.be.equal(name); }); }); + + describe('spy restore', function () { + var array; + + beforeEach(function () { + array = []; + chai.spy.on(array, 'push'); + }) + + it('should restore all methods of tracked objects', function () { + chai.spy.restore(); + + array.push.should.not.be.spy; + }); + + it('should restore all methods on an object', function () { + chai.spy.on(array, 'pop'); + chai.spy.restore(array); + + array.push.should.not.be.spy; + array.pop.should.not.be.spy; + }); + + it('should restore a particular method on an particular object', function () { + chai.spy.restore(array, 'push'); + + array.push.should.not.be.spy; + }); + + it('should not throw if there are not tracked objects', function () { + chai.spy.restore(); + + chai.spy.restore.should.not.throw(Error); + }); + }); });