Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assert.throws() must not call mismatched class as matcher function #1530

Closed
aaron-human opened this issue Dec 28, 2020 · 6 comments · Fixed by #1567
Closed

assert.throws() must not call mismatched class as matcher function #1530

aaron-human opened this issue Dec 28, 2020 · 6 comments · Fixed by #1567
Assignees
Labels

Comments

@aaron-human
Copy link

Tell us about your runtime:

  • QUnit version: 2.13.0 release, also the 2.10.1 release
  • What environment are you running QUnit in? (e.g., browser, Node): Firefox 84.0 64-bit on Ubuntu 18.04.5 LTS
  • How are you running QUnit? (e.g., script, testem, Grunt): Python script to host the CSS/JS/HTML on localhost.

What are you trying to do?

While testing a assert.throws(), I noticed that if the expected value (a class/constructor) didn't match the exception, QUnit would "die" rather than print that the error was the wrong type.

Code that reproduces the problem:

QUnit.test("Example", function (assert) {
    class CustomError extends Error {  }

    assert.throws(
        function(){
            throw new RangeError();
        },
        CustomError,
    );
});

If you have any relevant configuration information, please include that here: None that I know of.

What did you expect to happen?

Should indicate that an exception was thrown, but it didn't match (failed and instanceof check).

What actually happened?

The test run failed immediately.

What I see as output:

Died on test #1 @http://localhost:1000/test.js:1:7
: class constructors must be invoked with 'new'@ 3 ms
Source: 	

throws@http://localhost:1000/qunit-2.13.0.js:3998:61
@http://localhost:1000/test.js:4:12
runTest@http://localhost:1000/qunit-2.13.0.js:3044:32
run@http://localhost:1000/qunit-2.13.0.js:3032:15
runTest/<@http://localhost:1000/qunit-2.13.0.js:3255:15
processTaskQueue@http://localhost:1000/qunit-2.13.0.js:2640:26
processTaskQueue/<@http://localhost:1000/qunit-2.13.0.js:2644:28
promise callback*processTaskQueue@http://localhost:1000/qunit-2.13.0.js:2640:34
processTaskQueue/<@http://localhost:1000/qunit-2.13.0.js:2644:28
promise callback*processTaskQueue@http://localhost:1000/qunit-2.13.0.js:2640:34
advanceTaskQueue@http://localhost:1000/qunit-2.13.0.js:2625:20
advance@http://localhost:1000/qunit-2.13.0.js:2611:4
advanceTestQueue@http://localhost:1000/qunit-2.13.0.js:2670:4
advance@http://localhost:1000/qunit-2.13.0.js:2614:6
unblockAndAdvanceQueue@http://localhost:1000/qunit-2.13.0.js:4314:20
promise callback*begin@http://localhost:1000/qunit-2.13.0.js:4343:9
scheduleBegin/<@http://localhost:1000/qunit-2.13.0.js:4305:8
setTimeout handler*scheduleBegin@http://localhost:1000/qunit-2.13.0.js:4304:18
load@http://localhost:1000/qunit-2.13.0.js:4282:10
EventListener.handleEvent*addEvent@http://localhost:1000/qunit-2.13.0.js:5295:11
@http://localhost:1000/qunit-2.13.0.js:6154:14
@http://localhost:1000/qunit-2.13.0.js:6201:4
@http://localhost:1000/qunit-2.13.0.js:7302:2

I believe the issue is this code:

			// Expected is a constructor, maybe an Error constructor.
			// Note the extra check on its prototype - this is an implicit
			// requirement of "instanceof", else it will throw a TypeError.
			} else if ( expectedType === "function" &&
				expected.prototype !== undefined && actual instanceof expected ) {
				result = true;

			// Expected is an Error object
			} else if ( expectedType === "object" ) {

If the code was switched around to do this in the released JS file, it seems to work for the above check.

	        } else if (expectedType === "function" && expected.prototype !== undefined) {
	          result = (actual instanceof expected); // Expected is an Error object
	        } else if (expectedType === "object") {

I was going to setup a PR, but I'm not sure how to write tests that verify a QUnit test page reports a failure...

@smcclure15
Copy link
Member

smcclure15 commented Dec 29, 2020

Thanks the details and suggestions!
I verified that your proposal does avoid the "die", just running the CLI:

not ok 1 Example
  ---
  message: "Died on test #1     at Object.<anonymous> (/SNIP/demo.js:1:7)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Module.require (internal/modules/cjs/loader.js:952:19)
    at require (internal/modules/cjs/helpers.js:88:18)
    at run (/SNIP/src/cli/run.js:52:4): Class constructor CustomError cannot be invoked without 'new'"
  severity: failed
  actual  : null
  expected: undefined
  stack: TypeError: Class constructor CustomError cannot be invoked without 'new'
  ...

(but is that really the right message?)

We have a handful of "negative" assertion tests that this might fit into:
https://github.com/qunitjs/qunit/blob/master/test/main/assert.js#L578
although I'm not sure if that would cleanly distinguish how something failed, just that it did.

For more power, we have CLI tests that execute a test in isolation, and then verify its TAP output:
https://github.com/qunitjs/qunit/blob/master/test/cli/main.js
That could surely lock something like this down.

Note that while I was fiddling with your solution, I did notice that an existing unit test began failing:
https://github.com/qunitjs/qunit/blob/master/test/main/assert.js#L324
That is something to consider for compatibility.

EDITED

I copy/pasted the wrong output that I observed. This uses the proposed fix, and the die is avoided:

not ok 1 Example
  ---
  message: "failed"
  severity: failed
  actual  : "RangeError"
  expected: undefined
  stack:     at Object.<anonymous> (/SNIP/demo.js:4:12)
  ...

Still, the message is a little vague, and @Krinkle already has pointed out further complications that the failing unittests expose.

I can also report that the "die" message with the current behavior, the individual test bails out (with effectively a hard-error), and other tests do continue after recovery. Only with QUnit.config.notrycatch = true; does all execution really halt.

@Krinkle Krinkle added Component: Assert Component: Core For module, test, hooks, and reporters. Type: Bug Something isn't working right. labels Dec 29, 2020
@Krinkle Krinkle changed the title The assert.throws() Method Can Fail to Check Class Constructors assert.throws() dies on internal error when instanceof check fails Dec 29, 2020
@Krinkle
Copy link
Member

Krinkle commented Dec 29, 2020

The test run failed immediately.

I am unable to reproduce this. Both in the HTML runner and in the QUnit CLI, I find that the error is caught, reported, and any further tests continue executing. It does not appear to die immediately in a way that is different from normal assertion failures.

Of course, the error message is indeed incorrect. It should not be trying to call the class constructor as a validation callback (that is a different signature of assert.throws).

If the code was switched around to do this […], it seems to work for the above check.

	        } else if (expectedType === "function" && expected.prototype !== undefined) {
	          result = (actual instanceof expected); // Expected is an Error object
	        } else if (expectedType === "object") {

Aye, but I believe this would also break all uses of a matcher callback function, see https://api.qunitjs.com/assert/throws/:

The expectedMatcher argument can be:

  1. An Error object
  2. An Error constructor to use like errorValue instanceof expectedMatcher
  3. A RegExp that matches (or partially matches) the String representation
  4. A callback Function that must return true to pass the assertion check.

Such as:

  assert.throws(
    () => { throw new Error('foo'); },
    (err) => err.message === 'undefined'
  );

Ambigous signature

I regret not having realised this before, but there is an ambiguity in the assert.throws() signature. I would likely not have supported addition of it, had I realized it at the time...

Signature 2 and 4 are ambiguous, and I suspect have been for a very long time. Basically when check 2 fails, we (wrongly) run check 4 as well and it's pretty hard to avoid that in a way that won't introduce other failures. The two are inherently ambiguous since a constructor function is really no different from any other function in JavaScript.

assert.throws(
  () => { throw RangeError(); },
  RangeError
);

function CustomError(message) { this.message = message; }
assert.throws(
  () => { throw CustomError('foo'); },
  CustomError
);

assert.throws(
  () => { throw RangeError('foo'); },
  function (err) { return err.message === 'foo'; }
);

Despite how bad this is, it seems we've never run into it, and I can see why. The issue only happens if the test was already going to fail, and if the constructor can throw an exception.

The instanceof class signature is handled first, which means that we never call the callback function unless the author meant for it to be a callback function (and thus it either passes or fails in a way that makes sense), or if it was meant as a class and the instanceof check already failed. Even then, error constructors tend to be fairly simple and just return (implied) undefined and the test moves on to describe the failure the way that it should.

However, ES6 introduced classes with arrow-like constructor functions that throw by default if called without new. Thus making this unlikely scenario a lot more common!

function Foo() {}
Foo(); // ok

class Bar {}
Bar();
// Uncaught TypeError: class constructors must be invoked with 'new'

Bad handling for genuine callback function

As I mentioned, I'm unable to reproduce the issue, but if the HTML reporter and/or the QUnit CLI are dieing unexpectedly from this exception, then we likely also have an additional issue, since these user-provided functions must be allowed to fail. For example:

QUnit.test('example A', (assert) => {
  assert.throws(
    () => { throw new Error('foo'); },
    (err) => { return errTypo.message === 'undefined' }
  );
});
QUnit.test('example B', (assert) => {
  class Bar {}
  assert.throws(
    () => { throw new Error('foo'); },
    Bar
  );
});

The first one uses a matcher callback function with a typo causing it to throw a ReferenceError, and it seems to be handled correctly, same as for the reported case with an ES6 class:

TAP version 13
not ok 1 example A
  ---
  message: "Died on test #1     at …"
  severity: failed
  actual  : null
  expected: undefined
  stack: ReferenceError: errTypo is not defined
    at Object.<anonymous> (/Users/krinkle/Development/qunit/test/cli/fixtures/single.js:4:16)
  ...
not ok 2 example B
  ---
  message: "Died on test #1     at … "
  severity: failed
  actual  : null
  expected: undefined
  stack: TypeError: Class constructor Bar cannot be invoked without 'new'
  ...
1..2
# pass 0
# skip 0
# todo 0
# fail 2

Like I said, the error message is incorrect for the seconc test, but I did not find the test runner stopping unexpectedly. But, if it does indeed crash unexpectedly, then we likely need to fix that as well, even if we can fix the ambiguity, because it would also be an issue for exceptions from "real" matcher functions.

Path forward

Long-term, it seems that the only way to avoid this internal failure during check 4 is by introducing a stricter requirement for what we consider a "real" matcher function, and thus based on some hueristics (is/similar to ES6 class constructor) decide not to call it regardless of whether it would throw or not. I assume this can't be perfect, and thus would constitute a breaking change, and nothappen until QUnit 3. (I expect it to be very unlikely to affect anyone, and either way should be easy to fix when upgrading.)

In the short-term, we can:

  • Make sure the test runner doesn't crash, even if it still reports the wrong error message.
  • Perhaps detect the common case of ES6 constructors after a failure has already happened, and improve their error message, even if the behaviour is the same. (And then, in QUnit 3, we can use that detection before the error happens).

@smcclure15 Are you able to reproduce the crash?

As for detecting it, I haven't looked closely yet, but one way might be to inspect the actual value with toString and look for it startig with class (as required by the ES6 spec, [1], [2], [3]).

@aaron-human
Copy link
Author

aaron-human commented Dec 29, 2020

@Krinkle I didn't notice the test runner breaking (though I wasn't really looking for that), just that the assert.throws() call threw an exception, which ended the specific test case function. If possible, I'd want assert.throws() to avoid throwing anything so the test functions can continue even if one check fails out.

@Krinkle Krinkle changed the title assert.throws() dies on internal error when instanceof check fails assert.throws() must not call mismatched class as matcher function Dec 29, 2020
@Krinkle
Copy link
Member

Krinkle commented Dec 29, 2020

@aaron-human Thanks, that makes sense. I was hoping it was that! In general QUnit will nicely provide all failed assertions at once.

But, the "exception" is when there is an error throws unexpectedly by your test function or from other source code provided by you and called during a test. In this case, that is technically what is happening, but not supposed to. The failure you are encountering is essentially like the following:

QUnit.test('example', (assert) => {
  class CustomError {}
  assert.throws(
    () => { throw new Error('foo'); },
    (err) => CustomError() // supposed to return a boolean, but throws TypeError
  );
});

… except unlike the silly example above, it is not your fault that CustomError is called as a function, QUnit is doing this by mistake. I've updated the issue summary to focus on that instead.

@smcclure15
Copy link
Member

smcclure15 commented Jan 7, 2021

There's a final if/else branch in the throws comparisons:

// Expected is a validation function which returns true if validation passed
} else if ( expectedType === "function" && expected.call( {}, actual ) === true ) {
    expected = null;
    result = true;
}

That's the call invocation that throws the error. We've been focusing on constructor errors, but this could also come about with validation functions, eg:

function(err) {
    notdefined() // this throws!
}

We could more safely invoke the call within a try/catch:

} else if ( expectedType === "function" ) {
    try {
        result = expected.call( {}, actual ) === true;
        expected = null;
    } catch ( e ) {
        expected = errorString( e );
    }
}

and that will set the expected to a friendly error string. This gives the following result from the initial repro steps:

not ok 1 Example
  ---
  message: "failed"
  severity: failed
  actual  : "RangeError"
  expected: "TypeError: Class constructor CustomError cannot be invoked without 'new'"
  stack:     at Object.<anonymous> (/SNIP/demo.js:5:12)
  ...

This doesn't consider the special case of ES6 classes, but I think it shows worthwhile forward progress on this cryptic failure. Plus with this sort of change, it looks like all existing unittests pass, which increases my confidence in such iterative improvements.

@Krinkle
Copy link
Member

Krinkle commented Mar 13, 2021

@smcclure15 Agreed. It would display the same error message, but in relation to the assertion instead of as a "Died on test".

In QUnit 3 we could go a step further and tighten it up such that class-like values won't be tried as a validation function. That way, it can fail as actual: RangeError, expected: CustomError which is what you'd want.

@Krinkle Krinkle added help welcome and removed Component: Core For module, test, hooks, and reporters. labels Mar 13, 2021
@smcclure15 smcclure15 self-assigned this Mar 18, 2021
Krinkle pushed a commit that referenced this issue Apr 4, 2021
…es not match

Previously when the expected value was a class, and it did not satisfy instanceof,
the class function was also tried as a matcher function which in the case of
ES6 class constructors throws by default, which we did not internally catch,
causing a rather abrupt test failure rather than a more informative one.

Fixes #1530.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Development

Successfully merging a pull request may close this issue.

3 participants