From 547dc14cac4afad8d2e9dd91c5ffc617bc441eed Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Thu, 26 Dec 2019 13:47:25 -0800 Subject: [PATCH] =?UTF-8?q?[Breaking]=20`throws`:=20bring=20into=20line=20?= =?UTF-8?q?with=20node=E2=80=99s=20`assert.throws`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 2 +- lib/test.js | 46 +++++-- package.json | 1 + test/throws.js | 320 +++++++++++++++++++++++++++++++------------------ 4 files changed, 243 insertions(+), 126 deletions(-) diff --git a/.editorconfig b/.editorconfig index a356c3a7..1474ee61 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -max_line_length = 140 +max_line_length = 180 block_comment_start = /* block_comment = * block_comment_end = */ diff --git a/lib/test.js b/lib/test.js index fe40cd34..52fc76d0 100644 --- a/lib/test.js +++ b/lib/test.js @@ -8,8 +8,10 @@ var isRegExp = require('is-regex'); var trim = require('string.prototype.trim'); var bind = require('function-bind'); var forEach = require('for-each'); +var inspect = require('object-inspect'); var isEnumerable = bind.call(Function.call, Object.prototype.propertyIsEnumerable); var toLowerCase = bind.call(Function.call, String.prototype.toLowerCase); +var isProto = bind.call(Function.call, Object.prototype.isPrototypeOf); module.exports = Test; @@ -514,7 +516,7 @@ Test.prototype['throws'] = function (fn, expected, msg, extra) { fn(); } catch (err) { caught = { error: err }; - if ((err != null) && (!isEnumerable(err, 'message') || !has(err, 'message'))) { + if (err != null && (!isEnumerable(err, 'message') || !has(err, 'message'))) { var message = err.message; delete err.message; err.message = message; @@ -523,16 +525,42 @@ Test.prototype['throws'] = function (fn, expected, msg, extra) { var passed = caught; - if (isRegExp(expected)) { - passed = expected.test(caught && caught.error); - expected = String(expected); - } - - if (typeof expected === 'function' && caught) { - passed = caught.error instanceof expected; + if (caught) { + if (typeof expected === 'string' && caught.error && caught.error.message === expected) { + throw new TypeError('The "error/message" argument is ambiguous. The error message ' + inspect(expected) + ' is identical to the message.'); + } + if (typeof expected === 'function') { + if (typeof expected.prototype !== 'undefined' && caught.error instanceof expected) { + passed = true; + } else if (isProto(Error, expected)) { + passed = false; + } else { + passed = expected.call({}, caught.error) === true; + } + } else if (isRegExp(expected)) { + passed = expected.test(caught.error); + expected = inspect(expected); + } else if (expected && typeof expected === 'object') { // Handle validation objects. + var keys = Object.keys(expected); + // Special handle errors to make sure the name and the message are compared as well. + if (expected instanceof Error) { + keys.push('name', 'message'); + } else if (keys.length === 0) { + throw new TypeError('`throws` validation object must not be empty'); + } + passed = keys.every(function (key) { + if (typeof caught.error[key] === 'string' && isRegExp(expected[key]) && expected[key].test(caught.error[key])) { + return true; + } + if (key in caught.error && deepEqual(caught.error[key], expected[key], { strict: true })) { + return true; + } + return false; + }); + } } - this._assert(typeof fn === 'function' && passed, { + this._assert(!!passed, { message: defined(msg, 'should throw'), operator: 'throws', actual: caught && caught.error, diff --git a/package.json b/package.json index 4d6e122d..4b372491 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "is-regex": "^1.0.5", "minimist": "^1.2.0", "object-inspect": "^1.7.0", + "object.assign": "^4.1.0", "resolve": "^1.14.1", "resumer": "^0.0.0", "string.prototype.trim": "^1.2.1", diff --git a/test/throws.js b/test/throws.js index a919b4ec..e6af9dd2 100644 --- a/test/throws.js +++ b/test/throws.js @@ -1,6 +1,8 @@ var tape = require('../'); var tap = require('tap'); var concat = require('concat-stream'); +var inspect = require('object-inspect'); +var assign = require('object.assign'); var stripFullStack = require('./common').stripFullStack; @@ -33,118 +35,14 @@ tap.test('failures', function (tt) { stripFullStack(body.toString('utf8')), 'TAP version 13\n' + '# non functions\n' - + 'not ok 1 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage() + "] message: '" + getNonFunctionMessage() + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage(undefined) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 2 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage(null) + "] message: '" + getNonFunctionMessage(null) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage(null) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 3 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage(true) + "] message: '" + getNonFunctionMessage(true) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage(true) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 4 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage(false) + "] message: '" + getNonFunctionMessage(false) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage(false) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 5 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage('abc') + "] message: '" + getNonFunctionMessage('abc') + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage('abc') + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 6 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage(/a/g) + "] message: '" + getNonFunctionMessage(/a/g) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage(/a/g) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 7 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage([]) + "] message: '" + getNonFunctionMessage([]) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage([]) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' - + 'not ok 8 should throw\n' - + ' ---\n' - + ' operator: throws\n' - + ' expected: |-\n' - + ' undefined\n' - + ' actual: |-\n' - + " { [TypeError: " + getNonFunctionMessage({}) + "] message: '" + getNonFunctionMessage({}) + "' }\n" - + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' stack: |-\n' - + ' TypeError: ' + getNonFunctionMessage({}) + '\n' - + ' [... stack stripped ...]\n' - + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' - + ' [... stack stripped ...]\n' - + ' ...\n' + + 'ok 1 should throw\n' + + 'ok 2 should throw\n' + + 'ok 3 should throw\n' + + 'ok 4 should throw\n' + + 'ok 5 should throw\n' + + 'ok 6 should throw\n' + + 'ok 7 should throw\n' + + 'ok 8 should throw\n' + '# function\n' + 'not ok 9 should throw\n' + ' ---\n' @@ -178,10 +76,53 @@ tap.test('failures', function (tt) { + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' + ' [... stack stripped ...]\n' + ' ...\n' - + '\n1..14\n' - + '# tests 14\n' - + '# pass 4\n' - + '# fail 10\n' + + '# object\n' + + 'ok 15 object properties are validated\n' + + '# object with regexes\n' + + 'ok 16 object with regex values is validated\n' + + '# similar error object\n' + + 'ok 17 throwing a similar error\n' + + '# validate with regex\n' + + 'ok 18 regex against toString of error\n' + + '# custom error validation\n' + + 'ok 19 error is SyntaxError\n' + + 'ok 20 error matches /value/\n' + + 'ok 21 unexpected error\n' + + '# throwing primitives\n' + + 'ok 22 primitive: null\n' + + 'ok 23 primitive: undefined\n' + + 'ok 24 primitive: 0\n' + + 'ok 25 primitive: NaN\n' + + 'ok 26 primitive: 42\n' + + 'ok 27 primitive: Infinity\n' + + 'ok 28 primitive: \'\'\n' + + 'ok 29 primitive: \'foo\'\n' + + 'ok 30 primitive: true\n' + + 'ok 31 primitive: false\n' + + '# ambiguous arguments\n' + + 'ok 32 Second\n' + + 'ok 33 Second\n' + + 'ok 34 Second\n' + + 'ok 35 should throw\n' + + 'not ok 36 should throw\n' + + ' ---\n' + + ' operator: throws\n' + + ' expected: |-\n' + + ' \'/Second$/\'\n' + + ' actual: |-\n' + + ' { [Error: First] message: \'First\' }\n' + + ' at: Test. ($TEST/throws.js:$LINE:$COL)\n' + + ' stack: |-\n' + + ' Error: First\n' + + ' at throwingFirst ($TEST/throws.js:$LINE:$COL)\n' + + ' [... stack stripped ...]\n' + + ' at Test. ($TEST/throws.js:$LINE:$COL)\n' + + ' [... stack stripped ...]\n' + + ' ...\n' + + '\n1..36\n' + + '# tests 36\n' + + '# pass 33\n' + + '# fail 3\n' ); })); @@ -221,4 +162,151 @@ tap.test('failures', function (tt) { t.throws(function () { throw actual; }, TypeError, 'throws actual'); t.end(); }); + + // taken from https://nodejs.org/api/assert.html#assert_assert_throws_fn_error_message + var err = new TypeError('Wrong value'); + err.code = 404; + err.foo = 'bar'; + err.info = { + nested: true, + baz: 'text' + }; + err.reg = /abc/i; + + test('object', function (t) { + t.plan(1); + + t.throws( + function () { throw err; }, + { + name: 'TypeError', + message: 'Wrong value', + info: { + nested: true, + baz: 'text' + } + // Only properties on the validation object will be tested for. + // Using nested objects requires all properties to be present. Otherwise + // the validation is going to fail. + }, + 'object properties are validated' + ); + + t.end(); + }); + + test('object with regexes', function (t) { + t.plan(1); + t.throws( + function () { throw err; }, + { + // The `name` and `message` properties are strings and using regular + // expressions on those will match against the string. If they fail, an + // error is thrown. + name: /^TypeError$/, + message: /Wrong/, + foo: 'bar', + info: { + nested: true, + // It is not possible to use regular expressions for nested properties! + baz: 'text' + }, + // The `reg` property contains a regular expression and only if the + // validation object contains an identical regular expression, it is going + // to pass. + reg: /abc/i + }, + 'object with regex values is validated' + ); + t.end(); + }); + + test('similar error object', function (t) { + t.plan(1); + t.throws( + function () { + var otherErr = new TypeError('Not found'); + // Copy all enumerable properties from `err` to `otherErr`. + assign(otherErr, err); + throw otherErr; + }, + // The error's `message` and `name` properties will also be checked when using + // an error as validation object. + err, + 'throwing a similar error' + ); + t.end(); + }); + + test('validate with regex', function (t) { + t.plan(1); + t.throws( + function () { throw new Error('Wrong value'); }, + /^Error: Wrong value$/, + 'regex against toString of error' + ); + t.end(); + }); + + test('custom error validation', function (t) { + t.plan(3); + t.throws( + function () { throw new SyntaxError('Wrong value'); }, + function (error) { + t.ok(error instanceof SyntaxError, 'error is SyntaxError'); + t.ok((/value/).test(error), 'error matches /value/'); + // Avoid returning anything from validation functions besides `true`. + // Otherwise, it's not clear what part of the validation failed. Instead, + // throw an error about the specific validation that failed (as done in this + // example) and add as much helpful debugging information to that error as + // possible. + return true; + }, + 'unexpected error' + ); + t.end(); + }); + + test('throwing primitives', function (t) { + [null, undefined, 0, NaN, 42, Infinity, '', 'foo', true, false].forEach(function (primitive) { + t.throws(function () { throw primitive; }, 'primitive: ' + inspect(primitive)); + }); + + t.end(); + }); + + test('ambiguous arguments', function (t) { + function throwingFirst() { + throw new Error('First'); + } + + function throwingSecond() { + throw new Error('Second'); + } + + function notThrowing() {} + + // The second argument is a string and the input function threw an Error. + // The first case will not throw as it does not match for the error message + // thrown by the input function! + t.throws(throwingFirst, 'Second'); + // In the next example the message has no benefit over the message from the + // error and since it is not clear if the user intended to actually match + // against the error message, Node.js throws an `ERR_AMBIGUOUS_ARGUMENT` error. + t.throws(throwingSecond, 'Second'); + // TypeError [ERR_AMBIGUOUS_ARGUMENT] + + // The string is only used (as message) in case the function does not throw: + t.doesNotThrow(notThrowing, 'Second'); + // AssertionError [ERR_ASSERTION]: Missing expected exception: Second + + // If it was intended to match for the error message do this instead: + // It does not fail because the error messages match. + t.throws(throwingSecond, /Second$/); + + // If the error message does not match, an AssertionError is thrown. + t.throws(throwingFirst, /Second$/); + // AssertionError [ERR_ASSERTION] + t.end(); + }); });