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

Major rework #18

Closed
wants to merge 13 commits into from
Closed

Major rework #18

wants to merge 13 commits into from

Conversation

meeber
Copy link

@meeber meeber commented Nov 26, 2017

This is related to chaijs/chai#1021. The purpose of these changes is to simplify the existing Chai .throw assertion and move a lot of the logic into this module for easier reuse. These changes also help Chai and its plugins to provide more consistent failed assertion messages related to errors.

NOTE: I added a temporary version number to the package.json file to allow other modules to install this module from github for the purposes of demonstrating how these changes impact chai and chai-as-promised. It should be removed before merging. (But in the long term, I don't think we should continue excluding the version field from package.json... it makes development harder.)

Also related: chaijs/chai-as-promised#235

BREAKING CHANGE: Remove `.compatibleInstance` in preparation for the
upcoming addition of a `.checkError` method that'll require the thrown
object to be an `Error` instance. With that requirement in place, a
simple strict equality check takes the place of `.compatibleInstance`.
BREAKING CHANGE: Previously, `.getMessage` and `.compatibleMessage`
accepted both `Error` instances and strings. This commit removes
support for strings. This is being done in preparation for the
upcoming addition of a `.checkError` method that'll require the
thrown object to be an `Error` instance. With that requirement in
place, it's no longer necessary to support strings in `.getMessage`
and `.compatibleMessage`.
BREAKING CHANGE: Remove `.compatibleConstructor` in preparation for the
upcoming addition of a `.checkError` function that'll require `errLike`
to be either an `Error` instance or an `Error` constructor. With that
requirement in place, a simple strict equality check followed by an
`instanceof` check takes the place of `.compatibleConstructor`.
package.json Outdated
@@ -19,6 +19,7 @@
"check-error.js"
],
"main": "./index.js",
"version": "2.0.0-wip",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could change the version to something like 0.0.0-development which is a very loud signal that its not a valid practical version, but it is still would hopefully solve the issues you're seeing.

README.md Outdated
criteria = checkError.createCriteria(errObj);
checkError.describeExpectedError(criteria); // "[Error: I like waffles]"
checkError.checkError(new Error('i like waffles'), criteria); // false
checkError.checkError(errObj, criteria); // true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have time to give this the full review, but just from glancing at the readme I'm a little bit concerned over the notion of a criteria object. My initial questions are:

  • Why do I, as a consumer of this API, need to create an object before asserting on my error.
  • Why can't I just pass some arguments in place of criteria. E.g. checkError.checkError(errObj, new Error('I like waffles') or checkError.checkError(errObj, TypeError, /waffles/)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .createCriteria method performs quite a bit of type validation and argument handling (since two method signatures are supported) that is needed by both .checkError and .describeExpectedError. So the motivation behind the criteria object is only performing that work once. This may be over-engineered. It may be better overall to just bite the bullet and perform the work twice for the purposes of a simpler API. Easy change but will wait for more feedback before taking action.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured this was the case; it just feels a little un-ergonomic to have to create arguments using this API, to then give that argument to another part of the API (although not unprecedented, see DataView).

Perhaps we could make the functions that take a criteria polymorphic enough to either accept a criteria, or a criteria signature? This way we get the benefits of both.

We could do something like:

// Each method that wants a `criteria` will also take a criteria signature and coerce as necessary:
function checkError(errObj, ...criteriaLike) {
  const criteria = createCriteria(...criteriaLike)
  // ...
}

// `createCriteria(<Criteria>)` is essentially `identity`
function createCriteria(errLike, errMsgMatcher) {
  if (arguments.length === 1 && isCriteria(errLike) return errLike
  // ...
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keithamus Node v4 is still supported until April. Do we need to implement that logic with ES5?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, the arity semantics are likely of no concern so some sensible variant of function checkError(errObj, criteriaLikeA, criteriaLikeB) will be fine, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent idea.
I totally agree with @keithamus. This makes the API a lot more straightforward.

Copy link
Member

@lucasfcosta lucasfcosta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now this looks good, my only concern is with handling "non-Error" objects that might be thrown, such as strings.

According to our core docs we should support throwing anything.

Please correct me if I'm wrong, but if I'm not mistaken I think this line, for example, could be triggered and end up throwing an AssertionError if a String was thrown even though it matches the expected matcher.

README.md Outdated
criteria = checkError.createCriteria(errObj);
checkError.describeExpectedError(criteria); // "[Error: I like waffles]"
checkError.checkError(new Error('i like waffles'), criteria); // false
checkError.checkError(errObj, criteria); // true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent idea.
I totally agree with @keithamus. This makes the API a lot more straightforward.

// If `errorLike` is a constructor that inherits from Error, we compare `thrown` to `errorLike` directly
return thrown.constructor === errorLike || thrown instanceof errorLike;
function getMessage(errorLike) {
if (errorLike && errorLike.message) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the thrown error is not an object but a String instead?

Will we handle that within chai-core?

index.js Outdated

if ((criteria.errMsgMatcherType === 'string' ||
criteria.errMsgMatcherType === 'regexp') &&
!compatibleMessage(errObj, criteria.errMsgMatcher)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I've stated before, what happens if errObj is a string?

IMO if we plan on moving most of the logic to this module instead of keeping it in the core I think it would be suitable for us to deal with throwing Strings here too.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm basically making the argument with this PR and chaijs/chai#1021 that there shouldn't be direct support for throwing non-Error objects, as the logic required to support that bad practice is complex and dangerous. Dangerous because it allows the same assertion to pass in different situations. For example, expect(fn).to.throw('blah') would pass when fn is an Error object with the .message property containing "blah", or a string containing "blah", or just a regular object with a .message property containing "blah". As noted in the updated docs in chaijs/chai#1021, I believe people wishing to assert on non-Error objects should use assertion chaining instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a good reason to not support those.

Let's just not forget about updating our docs on the core then.

test/index.js Outdated
var criteria = checkError.createCriteria();
assert(checkError.checkError(notErrObj, criteria) === false);
});
/***************/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*Nitpick: can't we just use a linebreak instead of these separators?

@meeber
Copy link
Author

meeber commented Dec 27, 2017

@keithamus @lucasfcosta Just updated this PR based on prior comments

@meeber
Copy link
Author

meeber commented Jan 7, 2018

Bump!

README.md Outdated
* `compatibleMessage(err, errMatcher)` - Checks if an error message is compatible with an `errMatcher` RegExp or String (we check if the message contains the String).
* `checkError(errObj[, errLike[, errMsgMatcher]])` or `checkError(errObj[, errMsgMatcher])` - Checks if an `Error` instance matches criteria defined by `errLike` and/or `errMsgMatcher`.
* If `errLike` is an `Error` constructor, then validate that `errObj` is an instance of `errLike`.
* If `errLike` is an `Error` instance, then validate that `errObj` is strictly (`===`) equal to `errLike`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Given some of the talk in the chai issue tracker, and around deep-eql, should we losen this restriction to say

If errLike is an Error instance, then validate that errObj has the same constructor, message and any other enumerable properties (excluding stack)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favor of this change. But I think it'd need to be implemented in deep-eql and imported here. If one of the enumerable properties is an object, then I believe the normal deep equality algorithm should apply to that property.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, well said, let's leave it out of scope here, and work on putting this in deep-eql and bubbling up through the codebase.

* If `errLike` is an `Error` constructor, then validate that `errObj` is an instance of `errLike`.
* If `errLike` is an `Error` instance, then validate that `errObj` is strictly (`===`) equal to `errLike`.
* If `errLike` is a criteria object, then validate that `errObj` matches the criteria that was originally passed to `.createCriteria` in order to create the criteria object.
* If `errLike` is omitted or is explicitly `null` or `undefined`, then it defaults to the built-in `Error` constructor.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd like this at the top of this list, so the logic can fall down naturally - more reflective of the steps in the algorithm.

README.md Outdated
* `checkError(errObj[, errLike[, errMsgMatcher]])` or `checkError(errObj[, errMsgMatcher])` - Checks if an `Error` instance matches criteria defined by `errLike` and/or `errMsgMatcher`.
* If `errLike` is an `Error` constructor, then validate that `errObj` is an instance of `errLike`.
* If `errLike` is an `Error` instance, then validate that `errObj` is strictly (`===`) equal to `errLike`.
* If `errLike` is a criteria object, then validate that `errObj` matches the criteria that was originally passed to `.createCriteria` in order to create the criteria object.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step should be moved to the top - and maybe criteria object can be changed to criteria object (such as that made by `createCriteria`)

index.js Outdated
@@ -76,10 +56,11 @@ function compatibleConstructor(thrown, errorLike) {
*/

function compatibleMessage(thrown, errMatcher) {
var comparisonString = typeof thrown === 'string' ? thrown : thrown.message;
if (errMatcher instanceof RegExp) {
var errMatcherType = type(errMatcher).toLowerCase();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are literally just comparing regexp and string, a typeof/instanceof check would be better (faster) here.

index.js Outdated
@@ -101,7 +82,7 @@ function getConstructorName(errorLike) {
var constructorName = errorLike;
if (errorLike instanceof Error) {
constructorName = getFunctionName(errorLike.constructor);
} else if (typeof errorLike === 'function') {
} else if (type(errorLike) === 'function') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here - typeof would be better, as we closely control the flow here - type-detect is better put to use where we want to check object types. If we're only ever checking primitives, it's worth considering avoiding using type-detect entirely IMO

index.js Outdated
var errLikeType = type(errLike).toLowerCase();
var errMsgMatcherType = type(errMsgMatcher).toLowerCase();
// Handle a criteria object being passed by just returning the same object.
if (errLikeType === 'object' && isCriteria(errLike)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems isCriteria could add the additional typeof errLike === 'object' check. Additionally - might want to avoid type-detect here.

// Handle `errLike` being omitted and `errMsgMatcher` being passed as the
// second argument.
if (errLikeType === 'string' || errLikeType === 'regexp') {
if (errMsgMatcherType !== 'undefined' && errMsgMatcherType !== 'null') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More type stuff here could use typeof/instanceof.

index.js Outdated
var desc = '';
var constructor = getConstructorName(criteria.errLike);
var first = constructor.charAt(0).toLowerCase();
var vowels = [ 'a', 'e', 'i', 'o', 'u' ];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a premature optimisation, but this array doesn't need to be created each time. Could have it as a const

Copy link
Member

@keithamus keithamus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally a great improvement. Super well done 👍! I really like how chai is shaping up these days.

I think we should drop type-detect - I don't think it brings much value here, and adds to the complexity/performance characteristics of this codebase and would also introduce maintainence burdens if chai ends up shipping multiple versions of type-detect (like it did with deep-eql for a while).

Additionally, something to think about (maybe not for this PR) is ES6ifying this codebase. I'd like us to move to rollup so chai can ultimately ship as a single compiled file (for performance reasons) and ES6 will get us there.

- Add `.createCriteria`
- Add `.checkError`
- Add `.describeExpectedError`
The initial implemention of `.checkError` and `.describeExpectedError`
only accepted criteria objects for their criteria, which means that
`.createCriteria` had to be manually called first. This update allows
`errLike` and/or `errMsgMatcher` to be passed directly to those methods
instead, in which case a criteria object is created internally.
@meeber
Copy link
Author

meeber commented Jan 21, 2018

@keithamus I just updated this PR based on your review. (I'll need to remove the "wip version num" commit before we merge this though.)

@keithamus
Copy link
Member

I'll try to get a review done this week @meeber - but I might be too busy. Rest assured it's in my todo list to pick up as soon as I can.

BREAKING CHANGE: Previously, `Error` instances were compared using
strict equality. This commit changes the comparison algorithm to deep
equality. Users wishing to compare two `Error` instances via strict
equality should just use `===` directly instead of this module.
BREAKING CHANGE: This commit drops support for versions of Node below
v6, and updates the Travis config accordingly.
@keithamus keithamus deleted the branch chaijs:master October 8, 2021 09:51
@keithamus keithamus closed this Oct 8, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants