UNPKG

20.3 kBJavaScriptView Raw
1/*!
2 * chai-spies :: a chai plugin
3 * Copyright (c) 2012 Jake Luer <jake@alogicalparadox.com>
4 * MIT Licensed
5 */
6
7/*!
8 * We are going to export a function that can be used through chai
9 */
10
11module.exports = function (chai, _) {
12 // Easy access
13 var Assertion = chai.Assertion
14 , flag = _.flag
15 , i = _.inspect
16 , STATE_KEY = typeof Symbol === 'undefined' ? '__state' : Symbol('state')
17 , spyAmount = 0
18 , DEFAULT_SANDBOX = new Sandbox()
19 , noop = function () {}
20
21 /**
22 * # Sandbox constructor (function)
23 *
24 * Initialize new Sandbox instance
25 *
26 * @returns new sandbox
27 * @api private
28 */
29
30 function Sandbox() {
31 this[STATE_KEY] = {};
32 }
33
34 /**
35 * # Sandbox.on (function)
36 *
37 * Wraps an object method into spy assigned to sandbox. All calls will
38 * pass through to the original function.
39 *
40 * var spy = chai.spy.sandbox();
41 * var isArray = spy.on(Array, 'isArray');
42 *
43 * const array = []
44 * const spy = chai.spy.sandbox();
45 * const [push, pop] = spy.on(array, ['push', 'pop']);
46 *
47 * spy.on(array, 'push', returns => 1)
48 *
49 * @param {Object} object
50 * @param {String|String[]} method name or methods names to spy on
51 * @param {Function} [fn] mock implementation
52 * @returns created spy or created spies
53 * @api public
54 */
55
56 Sandbox.prototype.on = function (object, methodName, fn) {
57 if (Array.isArray(methodName)) {
58 return methodName.map(function (name) {
59 return this.on(object, name, fn);
60 }, this);
61 }
62
63 var isMethod = typeof object[methodName] === 'function'
64
65 if (methodName in object && !isMethod) {
66 throw new Error([
67 'Unable to spy property "', methodName,
68 '". Only methods and non-existing properties can be spied.'
69 ].join(''))
70 }
71
72 if (isMethod && object[methodName].__spy) {
73 throw new Error('"' + methodName + '" is already a spy')
74 }
75
76 var method = chai.spy('object.' + methodName, fn || object[methodName]);
77 var trackingId = ++spyAmount
78
79 this[STATE_KEY][trackingId] = method;
80 method.__spy.tracked = {
81 object: object
82 , methodName: methodName
83 , originalMethod: object[methodName]
84 , isOwnMethod: Object.hasOwnProperty.call(object, methodName)
85 };
86 object[methodName] = method;
87
88 return method;
89 };
90
91 /**
92 * # Sandbox.restore (function)
93 *
94 * Restores previously wrapped object's method.
95 * Restores all spied objects of a sandbox if called without parameters.
96 *
97 * var spy = chai.spy.sandbox();
98 * var object = spy.on(Array, 'isArray');
99 * spy.restore(Array, 'isArray'); // or spy.restore();
100 *
101 * @param {Object} [object]
102 * @param {String|String[]} [methods] method name or method names
103 * @return {Sandbox} Sandbox instance
104 * @api public
105 */
106
107 Sandbox.prototype.restore = function (object, methods) {
108 var exitAfter = 0, restored = 0;
109 var sandbox = this;
110
111 if (methods) {
112 if (!Array.isArray(methods)) {
113 methods = [methods]
114 }
115 exitAfter = methods.length;
116 }
117
118 Object.keys(this[STATE_KEY]).some(function (spyId) {
119 var spy = sandbox[STATE_KEY][spyId];
120 var tracked = spy.__spy.tracked;
121 var shouldRestoreThisObject = !object || object === tracked.object;
122 var shouldRestoreThisMethod = !methods || methods.indexOf(tracked.methodName) !== -1;
123
124 // this wouldn't work if we could provide a method without providing an object
125 if (!shouldRestoreThisObject || !shouldRestoreThisMethod) {
126 return false;
127 }
128
129 delete sandbox[STATE_KEY][spyId];
130 sandbox.restoreTrackedObject(spy);
131
132 if (++restored === exitAfter) {
133 return true;
134 }
135 });
136
137 return this;
138 };
139
140 /**
141 * # Sandbox.restoreTrackedObject (function)
142 *
143 * Restores tracked object's method
144 *
145 * var spy = chai.spy.sandbox();
146 * var isArray = spy.on(Array, 'isArray');
147 * spy.restoreTrackedObject(isArray);
148 *
149 * @param {Spy} spy
150 * @api private
151 */
152
153 Sandbox.prototype.restoreTrackedObject = function (spy) {
154 var tracked = spy.__spy.tracked;
155
156 if (!tracked) {
157 throw new Error('It is not possible to restore a non-tracked spy.')
158 }
159
160 if (tracked.isOwnMethod) {
161 tracked.object[tracked.methodName] = tracked.originalMethod;
162 } else {
163 delete tracked.object[tracked.methodName];
164 }
165
166 spy.__spy.tracked = null;
167 };
168
169 /**
170 * # chai.spy (function)
171 *
172 * Wraps a function in a proxy function. All calls will
173 * pass through to the original function.
174 *
175 * function original() {}
176 * var spy = chai.spy(original)
177 * , e_spy = chai.spy();
178 *
179 * @param {Function} function to spy on
180 * @returns function to actually call
181 * @api public
182 */
183
184 chai.spy = function (name, fn) {
185 if (typeof name === 'function') {
186 fn = name;
187 name = undefined;
188 }
189
190 fn = fn || noop;
191
192 function makeProxy (length, fn) {
193 switch (length) {
194 case 0 : return function () { return fn.apply(this, arguments); };
195 case 1 : return function (a) { return fn.apply(this, arguments); };
196 case 2 : return function (a,b) { return fn.apply(this, arguments); };
197 case 3 : return function (a,b,c) { return fn.apply(this, arguments); };
198 case 4 : return function (a,b,c,d) { return fn.apply(this, arguments); };
199 case 5 : return function (a,b,c,d,e) { return fn.apply(this, arguments); };
200 case 6 : return function (a,b,c,d,e,f) { return fn.apply(this, arguments); };
201 case 7 : return function (a,b,c,d,e,f,g) { return fn.apply(this, arguments); };
202 case 8 : return function (a,b,c,d,e,f,g,h) { return fn.apply(this, arguments); };
203 case 9 : return function (a,b,c,d,e,f,g,h,i) { return fn.apply(this, arguments); };
204 default : return function (a,b,c,d,e,f,g,h,i,j) { return fn.apply(this, arguments); };
205 }
206 };
207
208 var proxy = makeProxy(fn.length, function () {
209 var args = Array.prototype.slice.call(arguments);
210 proxy.__spy.calls.push(args);
211 proxy.__spy.called = true;
212 return fn.apply(this, args);
213 });
214
215 proxy.prototype = fn.prototype;
216 proxy.toString = function toString() {
217 var state = this.__spy;
218 var l = state.calls.length;
219 var s = "{ Spy";
220 if (state.name)
221 s += " '" + state.name + "'";
222 if (l > 0)
223 s += ", " + l + " call" + (l > 1 ? 's' : '');
224 s += " }";
225 return s + (fn !== noop ? "\n" + fn.toString() : '');
226 };
227
228 proxy.__spy = {
229 calls: []
230 , called: false
231 , name: name
232 };
233
234 return proxy;
235 }
236
237 /**
238 * # chai.spy.sandbox (function)
239 *
240 * Creates sandbox which allow to restore spied objects with spy.on.
241 * All calls will pass through to the original function.
242 *
243 * var spy = chai.spy.sandbox();
244 * var isArray = spy.on(Array, 'isArray');
245 *
246 * @param {Object} object
247 * @param {String} method name to spy on
248 * @returns passed object
249 * @api public
250 */
251
252 chai.spy.sandbox = function () {
253 return new Sandbox()
254 };
255
256 /**
257 * # chai.spy.on (function)
258 *
259 * The same as Sandbox.on.
260 * Assignes newly created spy to DEFAULT sandbox
261 *
262 * var isArray = chai.spy.on(Array, 'isArray');
263 *
264 * @see Sandbox.on
265 * @api public
266 */
267
268 chai.spy.on = function () {
269 return DEFAULT_SANDBOX.on.apply(DEFAULT_SANDBOX, arguments)
270 };
271
272 /**
273 * # chai.spy.interface (function)
274 *
275 * Creates an object interface with spied methods.
276 *
277 * var events = chai.spy.interface('Events', ['trigger', 'on']);
278 *
279 * var array = chai.spy.interface({
280 * push(item) {
281 * this.items = this.items || [];
282 * return this.items.push(item);
283 * }
284 * });
285 *
286 * @param {String|Object} name object or object name
287 * @param {String[]} [methods] method names
288 * @returns object with spied methods
289 * @api public
290 */
291
292 chai.spy.interface = function (name, methods) {
293 var defs = {};
294
295 if (name && typeof name === 'object') {
296 methods = Object.keys(name);
297 defs = name;
298 name = 'mock';
299 }
300
301 return methods.reduce(function (object, methodName) {
302 object[methodName] = chai.spy(name + '.' + methodName, defs[methodName]);
303 return object;
304 }, {});
305 };
306
307 /**
308 * # chai.spy.restore (function)
309 *
310 * The same as Sandbox.restore.
311 * Restores spy assigned to DEFAULT sandbox
312 *
313 * var array = []
314 * chai.spy.on(array, 'push');
315 * expect(array.push).to.be.spy // true
316 *
317 * chai.spy.restore()
318 * expect(array.push).to.be.spy // false
319 *
320 * @see Sandbox.restore
321 * @api public
322 */
323
324 chai.spy.restore = function () {
325 return DEFAULT_SANDBOX.restore.apply(DEFAULT_SANDBOX, arguments)
326 };
327
328 /**
329 * # chai.spy.returns (function)
330 *
331 * Creates a spy which returns static value.
332 *
333 * var method = chai.spy.returns(true);
334 *
335 * @param {*} value static value which is returned by spy
336 * @returns new spy function which returns static value
337 * @api public
338 */
339
340 chai.spy.returns = function (value) {
341 return chai.spy(function () {
342 return value;
343 });
344 };
345
346 /**
347 * # spy
348 *
349 * Assert the the object in question is an chai.spy
350 * wrapped function by looking for internals.
351 *
352 * expect(spy).to.be.spy;
353 * spy.should.be.spy;
354 *
355 * @api public
356 */
357
358 Assertion.addProperty('spy', function () {
359 this.assert(
360 'undefined' !== typeof this._obj.__spy
361 , 'expected ' + this._obj + ' to be a spy'
362 , 'expected ' + this._obj + ' to not be a spy');
363 return this;
364 });
365
366 /**
367 * # .called
368 *
369 * Assert that a spy has been called. Does not negate to allow for
370 * pass through language.
371 *
372 * @api public
373 */
374
375 function assertCalled (n) {
376 new Assertion(this._obj).to.be.spy;
377 var spy = this._obj.__spy;
378
379 if (n) {
380 this.assert(
381 spy.calls.length === n
382 , 'expected ' + this._obj + ' to have been called #{exp} but got #{act}'
383 , 'expected ' + this._obj + ' to have not been called #{exp}'
384 , n
385 , spy.calls.length
386 );
387 } else {
388 this.assert(
389 spy.called === true
390 , 'expected ' + this._obj + ' to have been called'
391 , 'expected ' + this._obj + ' to not have been called'
392 );
393 }
394 }
395
396 function assertCalledChain () {
397 new Assertion(this._obj).to.be.spy;
398 }
399
400 Assertion.addChainableMethod('called', assertCalled, assertCalledChain);
401
402 /**
403 * # once
404 *
405 * Assert that a spy has been called exactly once
406 *
407 * @api public
408 */
409
410 Assertion.addProperty('once', function () {
411 new Assertion(this._obj).to.be.spy;
412 this.assert(
413 this._obj.__spy.calls.length === 1
414 , 'expected ' + this._obj + ' to have been called once but got #{act}'
415 , 'expected ' + this._obj + ' to not have been called once'
416 , 1
417 , this._obj.__spy.calls.length );
418 });
419
420 /**
421 * # twice
422 *
423 * Assert that a spy has been called exactly twice.
424 *
425 * @api public
426 */
427
428 Assertion.addProperty('twice', function () {
429 new Assertion(this._obj).to.be.spy;
430 this.assert(
431 this._obj.__spy.calls.length === 2
432 , 'expected ' + this._obj + ' to have been called twice but got #{act}'
433 , 'expected ' + this._obj + ' to not have been called twice'
434 , 2
435 , this._obj.__spy.calls.length
436 );
437 });
438
439 /**
440 * # nth call (spy, n, arguments)
441 *
442 * Asserts that the nth call of the spy has been called with
443 *
444 */
445
446 function nthCallWith(spy, n, expArgs) {
447 if (spy.calls.length < n) return false;
448
449 var actArgs = spy.calls[n].slice()
450 , passed = 0;
451
452 expArgs.forEach(function (expArg) {
453 for (var i = 0; i < actArgs.length; i++) {
454 if (_.eql(actArgs[i], expArg)) {
455 passed++;
456 actArgs.splice(i, 1);
457 break;
458 }
459 }
460 });
461
462 return passed === expArgs.length
463 }
464
465 function numberOfCallsWith(spy, expArgs) {
466 var found = 0
467 , calls = spy.calls;
468
469 for (var i = 0; i < calls.length; i++) {
470 if (nthCallWith(spy, i, expArgs)) {
471 found++;
472 }
473 }
474
475 return found;
476 }
477
478 Assertion.addProperty('first', function () {
479 if ('undefined' !== this._obj.__spy) {
480 _.flag(this, 'spy nth call with', 1);
481 }
482 });
483
484 Assertion.addProperty('second', function () {
485 if ('undefined' !== this._obj.__spy) {
486 _.flag(this, 'spy nth call with', 2);
487 }
488 });
489
490 Assertion.addProperty('third', function () {
491 if ('undefined' !== this._obj.__spy) {
492 _.flag(this, 'spy nth call with', 3);
493 }
494 });
495
496 Assertion.addProperty('on');
497
498 Assertion.addChainableMethod('nth', function (n) {
499 if ('undefined' !== this._obj.__spy) {
500 _.flag(this, 'spy nth call with', n);
501 }
502 });
503
504 function generateOrdinalNumber(n) {
505 if (n === 1) return 'first';
506 if (n === 2) return 'second';
507 if (n === 3) return 'third';
508 return n + 'th';
509 }
510
511 /**
512 * ### .with
513 *
514 */
515
516 function assertWith() {
517 new Assertion(this._obj).to.be.spy;
518 var expArgs = [].slice.call(arguments, 0)
519 , spy = this._obj.__spy
520 , calls = spy.calls
521 , always = _.flag(this, 'spy always')
522 , nthCall = _.flag(this, 'spy nth call with');
523
524 if (always) {
525 var passed = numberOfCallsWith(spy, expArgs);
526 this.assert(
527 passed === calls.length
528 , 'expected ' + this._obj + ' to have been always called with #{exp} but got ' + passed + ' out of ' + calls.length
529 , 'expected ' + this._obj + ' to have not always been called with #{exp}'
530 , expArgs
531 );
532 } else if (nthCall) {
533 var ordinalNumber = generateOrdinalNumber(nthCall),
534 actArgs = calls[nthCall - 1];
535 new Assertion(this._obj).to.be.have.been.called.min(nthCall);
536 this.assert(
537 nthCallWith(spy, nthCall - 1, expArgs)
538 , 'expected ' + this._obj + ' to have been called at the ' + ordinalNumber + ' time with #{exp} but got #{act}'
539 , 'expected ' + this._obj + ' to have not been called at the ' + ordinalNumber + ' time with #{exp}'
540 , expArgs
541 , actArgs
542 );
543 } else {
544 var passed = numberOfCallsWith(spy, expArgs);
545 this.assert(
546 passed > 0
547 , 'expected ' + this._obj + ' to have been called with #{exp}'
548 , 'expected ' + this._obj + ' to have not been called with #{exp} but got ' + passed + ' times'
549 , expArgs
550 );
551 }
552 }
553
554 function assertWithChain () {
555 if ('undefined' !== this._obj.__spy) {
556 _.flag(this, 'spy with', true);
557 }
558 }
559
560 Assertion.addChainableMethod('with', assertWith, assertWithChain);
561
562 Assertion.addProperty('always', function () {
563 if ('undefined' !== this._obj.__spy) {
564 _.flag(this, 'spy always', true);
565 }
566 });
567
568 /**
569 * # exactly (n)
570 *
571 * Assert that a spy has been called exactly `n` times.
572 *
573 * @param {Number} n times
574 * @api public
575 */
576
577 Assertion.addMethod('exactly', function () {
578 new Assertion(this._obj).to.be.spy;
579 var always = _.flag(this, 'spy always')
580 , _with = _.flag(this, 'spy with')
581 , args = [].slice.call(arguments, 0)
582 , calls = this._obj.__spy.calls
583 , nthCall = _.flag(this, 'spy nth call with')
584 , passed;
585
586 if (always && _with) {
587 passed = 0
588 calls.forEach(function (call) {
589 if (call.length !== args.length) return;
590 if (_.eql(call, args)) passed++;
591 });
592
593 this.assert(
594 passed === calls.length
595 , 'expected ' + this._obj + ' to have been always called with exactly #{exp} but got ' + passed + ' out of ' + calls.length
596 , 'expected ' + this._obj + ' to have not always been called with exactly #{exp}'
597 , args
598 );
599 } else if(_with && nthCall) {
600 var ordinalNumber = generateOrdinalNumber(nthCall),
601 actArgs = calls[nthCall - 1];
602 new Assertion(this._obj).to.be.have.been.called.min(nthCall);
603 this.assert(
604 _.eql(actArgs, args)
605 , 'expected ' + this._obj + ' to have been called at the ' + ordinalNumber + ' time with exactly #{exp} but got #{act}'
606 , 'expected ' + this._obj + ' to have not been called at the ' + ordinalNumber + ' time with exactly #{exp}'
607 , args
608 , actArgs
609 );
610 } else if (_with) {
611 passed = 0;
612 calls.forEach(function (call) {
613 if (call.length !== args.length) return;
614 if (_.eql(call, args)) passed++;
615 });
616
617 this.assert(
618 passed > 0
619 , 'expected ' + this._obj + ' to have been called with exactly #{exp}'
620 , 'expected ' + this._obj + ' to not have been called with exactly #{exp} but got ' + passed + ' times'
621 , args
622 );
623 } else {
624 this.assert(
625 this._obj.__spy.calls.length === args[0]
626 , 'expected ' + this._obj + ' to have been called #{exp} times but got #{act}'
627 , 'expected ' + this._obj + ' to not have been called #{exp} times'
628 , args[0]
629 , this._obj.__spy.calls.length
630 );
631 }
632 });
633
634 /**
635 * # gt (n)
636 *
637 * Assert that a spy has been called more than `n` times.
638 *
639 * @param {Number} n times
640 * @api public
641 */
642
643 function above (_super) {
644 return function (n) {
645 if ('undefined' !== typeof this._obj.__spy) {
646 new Assertion(this._obj).to.be.spy;
647
648 this.assert(
649 this._obj.__spy.calls.length > n
650 , 'expected ' + this._obj + ' to have been called more than #{exp} times but got #{act}'
651 , 'expected ' + this._obj + ' to have been called at most #{exp} times but got #{act}'
652 , n
653 , this._obj.__spy.calls.length
654 );
655 } else {
656 _super.apply(this, arguments);
657 }
658 }
659 }
660
661 Assertion.overwriteMethod('above', above);
662 Assertion.overwriteMethod('gt', above);
663
664 /**
665 * # lt (n)
666 *
667 * Assert that a spy has been called less than `n` times.
668 *
669 * @param {Number} n times
670 * @api public
671 */
672
673 function below (_super) {
674 return function (n) {
675 if ('undefined' !== typeof this._obj.__spy) {
676 new Assertion(this._obj).to.be.spy;
677
678 this.assert(
679 this._obj.__spy.calls.length < n
680 , 'expected ' + this._obj + ' to have been called fewer than #{exp} times but got #{act}'
681 , 'expected ' + this._obj + ' to have been called at least #{exp} times but got #{act}'
682 , n
683 , this._obj.__spy.calls.length
684 );
685 } else {
686 _super.apply(this, arguments);
687 }
688 }
689 }
690
691 Assertion.overwriteMethod('below', below);
692 Assertion.overwriteMethod('lt', below);
693
694 /**
695 * # min (n)
696 *
697 * Assert that a spy has been called `n` or more times.
698 *
699 * @param {Number} n times
700 * @api public
701 */
702
703 function min (_super) {
704 return function (n) {
705 if ('undefined' !== typeof this._obj.__spy) {
706 new Assertion(this._obj).to.be.spy;
707
708 this.assert(
709 this._obj.__spy.calls.length >= n
710 , 'expected ' + this._obj + ' to have been called at least #{exp} times but got #{act}'
711 , 'expected ' + this._obj + ' to have been called fewer than #{exp} times but got #{act}'
712 , n
713 , this._obj.__spy.calls.length
714 );
715 } else {
716 _super.apply(this, arguments);
717 }
718 }
719 }
720
721 Assertion.overwriteMethod('min', min);
722 Assertion.overwriteMethod('least', min);
723
724 /**
725 * # max (n)
726 *
727 * Assert that a spy has been called `n` or fewer times.
728 *
729 * @param {Number} n times
730 * @api public
731 */
732
733 function max (_super) {
734 return function (n) {
735 if ('undefined' !== typeof this._obj.__spy) {
736 new Assertion(this._obj).to.be.spy;
737
738 this.assert(
739 this._obj.__spy.calls.length <= n
740 , 'expected ' + this._obj + ' to have been called at most #{exp} times but got #{act}'
741 , 'expected ' + this._obj + ' to have been called more than #{exp} times but got #{act}'
742 , n
743 , this._obj.__spy.calls.length
744 );
745 } else {
746 _super.apply(this, arguments);
747 }
748 }
749 }
750
751 Assertion.overwriteMethod('max', max);
752 Assertion.overwriteMethod('most', max);
753};