diff --git a/lib/test.js b/lib/test.js index 43bf905e..780ba1f9 100644 --- a/lib/test.js +++ b/lib/test.js @@ -15,6 +15,7 @@ var inspect = require('object-inspect'); var is = require('object-is'); var objectKeys = require('object-keys'); var every = require('array.prototype.every'); +var mockProperty = require('mock-property'); var isEnumerable = callBound('Object.prototype.propertyIsEnumerable'); var toLowerCase = callBound('String.prototype.toLowerCase'); @@ -26,6 +27,7 @@ var $replace = callBound('String.prototype.replace'); var $strSlice = callBound('String.prototype.slice'); var $push = callBound('Array.prototype.push'); var $shift = callBound('Array.prototype.shift'); +var $slice = callBound('Array.prototype.slice'); var nextTick = typeof setImmediate !== 'undefined' ? setImmediate @@ -204,6 +206,75 @@ Test.prototype.teardown = function teardown(fn) { } }; +function wrapFunction(original) { + if (typeof original !== 'undefined' && typeof original !== 'function') { + throw new TypeError('`original` must be a function or `undefined`'); + } + + var bound = original && callBind.apply(original); + + var calls = []; + + var wrapObject = { + __proto__: null, + wrapped: function wrapped() { + var args = $slice(arguments); + var completed = false; + try { + var returned = original ? bound(this, arguments) : void undefined; + $push(calls, { args: args, receiver: this, returned: returned }); + completed = true; + return returned; + } finally { + if (!completed) { + $push(calls, { args: args, receiver: this, threw: true }); + } + } + }, + calls: calls, + results: function results() { + try { + return calls; + } finally { + calls = []; + wrapObject.calls = calls; + } + } + }; + return wrapObject; +} + +Test.prototype.capture = function capture(obj, method) { + if (!obj || (typeof obj !== 'object' && typeof obj !== 'function')) { + throw new TypeError('`obj` must be an object'); + } + if (typeof method !== 'string' && typeof method !== 'symbol') { + throw new TypeError('`method` must be a string or a symbol'); + } + var implementation = arguments.length > 2 ? arguments[2] : void undefined; + if (typeof implementation !== 'undefined' && typeof implementation !== 'function') { + throw new TypeError('`implementation`, if provided, must be a function'); + } + + var wrapper = wrapFunction(implementation); + var restore = mockProperty(obj, method, { value: wrapper.wrapped }); + this.teardown(restore); + + wrapper.results.restore = restore; + + return wrapper.results; +}; + +Test.prototype.captureFn = function captureFn(original) { + if (typeof original !== 'function') { + throw new TypeError('`original` must be a function'); + } + + var wrapObject = wrapFunction(original); + wrapObject.wrapped.calls = wrapObject.calls; + return wrapObject.wrapped; +}; + Test.prototype._end = function _end(err) { var self = this; diff --git a/package.json b/package.json index 187628ce..12b55852 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "inherits": "^2.0.4", "is-regex": "^1.1.4", "minimist": "^1.2.8", + "mock-property": "^1.0.0", "object-inspect": "^1.12.3", "object-is": "^1.1.5", "object-keys": "^1.1.1", @@ -58,6 +59,7 @@ "es-value-fixtures": "^1.4.2", "eslint": "=8.8.0", "falafel": "^2.2.5", + "intl-fallback-symbol": "^1.0.0", "jackspeak": "=2.1.1", "js-yaml": "^3.14.0", "npm-run-posix-or-windows": "^2.0.2", diff --git a/readme.markdown b/readme.markdown index c804c1c8..992f5e50 100644 --- a/readme.markdown +++ b/readme.markdown @@ -384,7 +384,8 @@ You may pass the same options that [`test()`](#testname-opts-cb) accepts. ## t.comment(message) -Print a message without breaking the tap output. (Useful when using e.g. `tap-colorize` where output is buffered & `console.log` will print in incorrect order vis-a-vis tap output.) +Print a message without breaking the tap output. +(Useful when using e.g. `tap-colorize` where output is buffered & `console.log` will print in incorrect order vis-a-vis tap output.) Multiline output will be split by `\n` characters, and each one printed as a comment. @@ -396,6 +397,29 @@ Assert that `string` matches the RegExp `regexp`. Will fail when the first two a Assert that `string` does not match the RegExp `regexp`. Will fail when the first two arguments are the wrong type. +## t.capture(obj, method, implementation = () => {}) + +Replaces `obj[method]` with the supplied implementation. +`obj` must be a non-primitive, `method` must be a valid property key (string or symbol), and `implementation`, if provided, must be a function. + +Calling the returned `results()` function will return an array of call result objects. +The array of calls will be reset whenever the function is called. +Call result objects will match one of these forms: + - `{ args: [x, y, z], receiver: o, returned: a }` + - `{ args: [x, y, z], receiver: o, threw: true }` + +The replacement will automatically be restored on test teardown. +You can restore it manually, if desired, by calling `.restore()` on the returned results function. + +Modeled after [tap](https://tapjs.github.io/tapjs/modules/_tapjs_intercept.html). + +## t.captureFn(original) + +Wraps the supplied function. +The returned wrapper has a `.calls` property, which is an array that will be populated with call result objects, described under `t.capture()`. + +Modeled after [tap](https://tapjs.github.io/tapjs/modules/_tapjs_intercept.html). + ## var htest = test.createHarness() Create a new test harness instance, which is a function like `test()`, but with a new pending stack and test state. diff --git a/test/capture.js b/test/capture.js new file mode 100644 index 00000000..6225f963 --- /dev/null +++ b/test/capture.js @@ -0,0 +1,132 @@ +'use strict'; + +var tape = require('../'); +var tap = require('tap'); +var concat = require('concat-stream'); +var inspect = require('object-inspect'); +var forEach = require('for-each'); +var v = require('es-value-fixtures'); + +var stripFullStack = require('./common').stripFullStack; + +tap.test('capture: output', function (tt) { + tt.plan(1); + + var test = tape.createHarness(); + var count = 0; + test.createStream().pipe(concat(function (body) { + tt.same(stripFullStack(body.toString('utf8')), [].concat( + 'TAP version 13', + '# argument validation', + v.primitives.map(function (x) { + return 'ok ' + ++count + ' ' + inspect(x) + ' is not an Object'; + }), + v.nonPropertyKeys.map(function (x) { + return 'ok ' + ++count + ' ' + inspect(x) + ' is not a valid property key'; + }), + v.nonFunctions.filter(function (x) { return typeof x !== 'undefined'; }).map(function (x) { + return 'ok ' + ++count + ' ' + inspect(x) + ' is not a function'; + }), + '# captures calls', + 'ok ' + ++count + ' property has expected initial value', + '# capturing', + 'ok ' + ++count + ' throwing implementation throws', + 'ok ' + ++count + ' should be deeply equivalent', + 'ok ' + ++count + ' should be deeply equivalent', + 'ok ' + ++count + ' should be deeply equivalent', + 'ok ' + ++count + ' should be deeply equivalent', + 'ok ' + ++count + ' should be deeply equivalent', + '# post-capturing', + 'ok ' + ++count + ' property is restored', + 'ok ' + ++count + ' added property is removed', + '', + '1..' + count, + '# tests ' + count, + '# pass ' + count, + '', + '# ok', + '' + )); + })); + + test('argument validation', function (t) { + forEach(v.primitives, function (primitive) { + t.throws( + function () { t.capture(primitive, ''); }, + TypeError, + inspect(primitive) + ' is not an Object' + ); + }); + + forEach(v.nonPropertyKeys, function (nonPropertyKey) { + t.throws( + function () { t.capture({}, nonPropertyKey); }, + TypeError, + inspect(nonPropertyKey) + ' is not a valid property key' + ); + }); + + forEach(v.nonFunctions, function (nonFunction) { + if (typeof nonFunction !== 'undefined') { + t.throws( + function () { t.capture({}, '', nonFunction); }, + TypeError, + inspect(nonFunction) + ' is not a function' + ); + } + }); + + t.end(); + }); + + test('captures calls', function (t) { + var sentinel = { sentinel: true, inspect: function () { return '{ SENTINEL OBJECT }'; } }; + var o = { foo: sentinel, inspect: function () { return '{ o OBJECT }'; } }; + t.equal(o.foo, sentinel, 'property has expected initial value'); + + t.test('capturing', function (st) { + var results = st.capture(o, 'foo', function () { return sentinel; }); + var results2 = st.capture(o, 'foo2'); + var up = new SyntaxError('foo'); + var resultsThrow = st.capture(o, 'fooThrow', function () { throw up; }); + + o.foo(1, 2, 3); + o.foo(3, 4, 5); + o.foo2.call(sentinel, 1); + st.throws( + function () { o.fooThrow(1, 2, 3); }, + SyntaxError, + 'throwing implementation throws' + ); + + st.deepEqual(results(), [ + { args: [1, 2, 3], receiver: o, returned: sentinel }, + { args: [3, 4, 5], receiver: o, returned: sentinel } + ]); + st.deepEqual(results(), []); + + o.foo(6, 7, 8); + st.deepEqual(results(), [ + { args: [6, 7, 8], receiver: o, returned: sentinel } + ]); + + st.deepEqual(results2(), [ + { args: [1], receiver: sentinel, returned: undefined } + ]); + st.deepEqual(resultsThrow(), [ + { args: [1, 2, 3], receiver: o, threw: true } + ]); + + st.end(); + }); + + t.test('post-capturing', function (st) { + st.equal(o.foo, sentinel, 'property is restored'); + st.notOk('foo2' in o, 'added property is removed'); + + st.end(); + }); + + t.end(); + }); +}); diff --git a/test/captureFn.js b/test/captureFn.js new file mode 100644 index 00000000..9bd4a9cf --- /dev/null +++ b/test/captureFn.js @@ -0,0 +1,75 @@ +'use strict'; + +var tape = require('../'); +var tap = require('tap'); +var concat = require('concat-stream'); +var inspect = require('object-inspect'); +var forEach = require('for-each'); +var v = require('es-value-fixtures'); + +var stripFullStack = require('./common').stripFullStack; + +tap.test('captureFn: output', function (tt) { + tt.plan(1); + + var test = tape.createHarness(); + var count = 0; + test.createStream().pipe(concat(function (body) { + tt.same(stripFullStack(body.toString('utf8')), [].concat( + 'TAP version 13', + '# argument validation', + v.nonFunctions.map(function (x) { + return 'ok ' + ++count + ' ' + inspect(x) + ' is not a function'; + }), + '# captured fn calls', + 'ok ' + ++count + ' return value is passed through', + 'ok ' + ++count + ' throwing implementation throws', + 'ok ' + ++count + ' should be deeply equivalent', + 'ok ' + ++count + ' should be deeply equivalent', + '', + '1..' + count, + '# tests ' + count, + '# pass ' + count, + '', + '# ok', + '' + )); + })); + + test('argument validation', function (t) { + forEach(v.nonFunctions, function (nonFunction) { + t.throws( + function () { t.captureFn(nonFunction); }, + TypeError, + inspect(nonFunction) + ' is not a function' + ); + }); + + t.end(); + }); + + test('captured fn calls', function (t) { + var sentinel = { sentinel: true, inspect: function () { return '{ SENTINEL OBJECT }'; } }; + + var wrappedSentinelThunk = t.captureFn(function () { return sentinel; }); + var up = new SyntaxError('foo'); + var wrappedThrower = t.captureFn(function () { throw up; }); + + t.equal(wrappedSentinelThunk(1, 2), sentinel, 'return value is passed through'); + t.throws( + function () { wrappedThrower.call(sentinel, 1, 2, 3); }, + SyntaxError, + 'throwing implementation throws' + ); + + t.deepEqual(wrappedSentinelThunk.calls, [ + { args: [1, 2], receiver: undefined, returned: sentinel } + ]); + + t.deepEqual(wrappedThrower.calls, [ + { args: [1, 2, 3], receiver: sentinel, threw: true } + ]); + + t.end(); + }); +});