-
-
Notifications
You must be signed in to change notification settings - Fork 702
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
chai.assert.strictEqual should warn when comparing two undefined values #870
Comments
@mlee Thanks for the issue. In #726, I wrote:
That quote was mostly in reference to misspelled assertions (e.g., I'm flagging this as "more-discussion-needed" for now. Some things to consider:
|
I don't see any way we can accomplish this in Chai alone. Please, consider a simple example like: assert.strictEqual(objA[key], objB[key]) If both values are assert.strictEqual(objA[key], undefined) I would like to get a warning that This issue reminded me of Maybe we should have custom ESLint plugin (like AVA has) to help writing better assertions. |
I really like this idea, and I think maybe this would be the right way to go. I think while we probably could banish use of undefined from our equality tests, it would violate the principle of least surprise, as @shvaikalesh demonstrated above. In reality, I think if you access a property of an object, you should be making some assertions about that, for example: assert.property(objA, key);
assert.isDefined(objA[key]);
assert.isEqual(objA[key], objB[key]); Or, of course, using the more expressive syntaxes
For my own tests, I often find myself asserting on stuff that some argue is a waste of time, for example a recent codebase Im working on has stuff like: describe('foo', () => {
it('is a function', () => expect(foo).to.be.a('function'))
it('takes 2 arguments, returning a boolean', () => expect(foo(1, 2)).to.be.a('boolean'))
it('throws if given 1 argument', () => expect(() => foo(1)).to.throw(TypeError, 'missing 2nd param')
}); This become very useful, because having a simple test like |
@shvaikalesh Why wouldn't Chai be able to accomplish this alone (from a technical perspective, irregardless of whether or not it should)? I think your first example ( Wouldn't the implementation be something along the lines of: assert.strictEqual = function (act, exp, msg) {
if (act === undefined && exp === undefined) {
throw Error("Invalid Chai assertion: `assert.strictEqual` cannot be used to compare `undefined` values. Instead, use `assert.isUndefined`. See the docs for more info.");
}
new Assertion(act, msg).to.equal(exp);
}; The above protects against writing a test that looks like it's valid, but is actually invalid: assert.strictEqual(objA[misspelledKey], objB[misspelledKey]); // Only passes because both are undefined Or maybe instead of "misspelledKey", the code was just refactored and the key names changed, but the developer forgot to update the tests accordingly. Unfortunately, the tests will continue passing despite the refactor to the code because @keithamus A linting plugin wouldn't be able to detect the kind of problems that @mlee is attempting to address here, as exemplified above. However, your testing style would catch those kinds of problems. I personally subscribe to the same kind of style for the same reason. But I'm not sure we can rely on other developers to use this style. Edit: Just to reiterate for the sake of clarity: The purpose of this protection is to prevent writing a test that appears to be valid but is actually invalid and is only passing because |
The linter is able to detect when you are accessing properties on an object, to pass into the expectation method (chai is not, as it can only be done with static code analysis). So it can warn you when passing in potentially undefined objects to the expect(foo).to.equal(bar.baz); // eslint-error: no-expected-property Do not pass object properties into `.equal`, as they may be undefined
expect(foo).to.equal(undefined); // this passes lint, as it is explicitly undefined |
To expand, the real problem @mlee is having, is that they are giving potentially undefined values to one of our methods. We could fix this, by disallowing undefined values in our methods - but that's as much of a footgun (IMO) and a slippery slope for us. Do we just ban undefined values? What about null? What about falsey values? What if someone wants to test a method is returning explicitly undefined? The real fix for @mlee is to fix the broken/insufficient tests. Tests need to be written defensively, arguably more than code. I'm not laying blame with @mlee - but also not laying blame with our methods. This is a soft problem - one of coding style, not one chai can fix, and not one developers can be taught by chai. I think a mediated solution is the right one; for example opting into static code analysis with something like eslint. |
@keithamus We agree on general principles, but I think we disagree on the severity of this problem as well as the urgency and cost/benefit of addressing the problem directly in Chai, as opposed to relying on opt-in linting or better developer education. Let me attempt to explain my viewpoint with an example and analysis (which ties into some of the points you made): it("cloned cat has the same color as original cat after cloning", function () {
var cat = new Cat("blue");
var clonedCat = cloneCat(cat);
expect(clonedCat.color).to.equal(cat.color);
}); If two months from now, another developer swoops in and refactors the Cat class to change So I ask myself:
And I answer:
Regarding the slippery slope: I think each problem should be evaluated independently. My feeling is that Also, I'm not sure if the "do we just ban undefined values?" question is applicable because the problem being discussed here affects tests that aren't supposed to be working with |
I appreciate all of your points, and you're not really wrong with anything you say. I suppose my point is more: we cannot stop developers writing bad code, we cannot stop developers writing bad tests. We can mitigate it, but it comes at the cost of making testing more awkward in general, IMO. The problem the OP has is just one instance of a general problem; relying on circular tests without stringent additional assertions. To take your test example: it("cloned cat has the same color as original cat after cloning", function () {
var cat = new Cat("blue");
var clonedCat = cloneCat(cat);
expect(clonedCat.color).to.equal(cat.color);
}); What if my cloneCat function looks like this: function cloneCat(cat) {
cat.color = false;
return cat;
} The tests pass, despite some obviously broken code, and Chai would have no reasonable way to prevent you doing the above. Who is at fault does not matter, this class of problems is, as you say, easy to trip up on. It is not uncommon to have accidentally passing tests when refactoring - and for this kind of situation to occur (although obviously not nearly as contrived). The crux of the issue here is: people need to write better tests. We can make our assertions try to be more clever, but ultimately they'll just assert on values, and it will never fix the root problem; that people need to write better tests. FWIW, anyone reading this and thinking "how can I fix this in my code? How can I make sure my tests are robust", I offer these rules of thumb:
|
Also to add;
I don't think we do - per se, I think our disagreement lies with what we feel can be done. I agree this is a big problem, and one that needs to be fixed well. If we could find a solution within chai that could fix this, I'd be happy. But I don't think one exists, the reason being: I think this is more a problem of education. We can educate developers on potential mistakes via analysis of given values, this is useful, but ultimately limited in scope, and outside of the scope of fixing this class of problem (IMO). A tool like eslint can educate developers on potential mistakes, via static code analysis - which is where the class of problem lies. |
@keithamus Thank you for taking the time to have this discussion! I totally agree that there's a broader issue here that can only be solved through education and writing better tests. The three points of advice that you provided are all spot-on. I also agree that there's no way Chai could defend against errors in logic like the one you used in your example; the only solution in such cases is to write better tests. I also agree there are a lot of similarities between my original example, which could be defended against by Chai, and your example, which could not. And I understand the argument that Chai shouldn't defend against the problem in my original example if it can't also defend against the broader problem, such as the one demonstrated in your example, especially because there's a cost associated with fixing the problem in my original example, and because there are other potential solutions such as linting that don't have that cost. Nevertheless, I'm still on the fence on this particular issue. I feel like it's at least possible that the benefit of protecting against the accidental The point is, I'm still not yet convinced either way. I'd like to think about it some more, and I'd also love to hear some more opinions: @vieiralucas @lucasfcosta @shvaikalesh. |
Hello everyone, awesome considerations and great arguments, from both sides. I think it' a developer's responsibility to write testes defensively and that they should be maintained instead of just being trusted after every change, after all, test is code and it should be maintained as such, just as it happens with application code. If I were a developer I'd really expect Regarding the example @meeber used, this is what I have to say:
I think a linting options file would be far better. It keeps the concerns separated and then we would avoid adding this kind of code to our core, which (as I already said) is not our responsibility (IMO). This pretty much resumes my opinion on this matter:
|
@lucasfcosta Thanks for sharing your thoughts!
I agree, but that doesn't mean that this idea should be automatically rejected on principle. Instead, every idea should be evaluated independently based on a cost/benefit analysis. There are times when Chai will be able to help developers avoid common pitfalls at a reasonable cost, and there are times when the cost will be too great. I haven't made up my mind yet on this issue. However, I suspect that even defensive programmers are vulnerable to this type of problem; it's really not that outrageous or uncommon of a mistake to forget to type-check a value prior to an equality assertion.
Either I'm underestimating the negative impact this change would have on developers, or you and @keithamus are overestimating the negative impact. Perhaps both. This is another area that I haven't made up my mind. I'm still evaluating. My opinion at the moment is that if I was a new developer using Chai and I wrote an
The details of the example could be reasonably changed to counter your argument. For example, maybe the
Yes, there's no debate about whether or not the test can and should be written in a better way to avoid this problem. But it's my opinion that the kind of mistake being discussed here is easy to make, non-obvious, and destructive. I believe even an experienced, defensive programmer can become vulnerable to this kind of problem. And even though developer education is definitely part of the solution, it's not mutually exclusive with Chai implementing features that help the developer to avoid common mistakes if it's reasonable to do so from a cost/benefit perspective. Which to me is the main question here.
Agreed. As I mentioned above, there are scenarios where this will annoy users, especially in regard to existing assertions, and it's the main reason I haven't fully embraced this idea yet. The cost is high in some scenarios. I suspect it's pretty low in most scenarios, though. I'm still not sure yet what that means for the overall cost/benefit ratio. All I know is that I value the benefit so greatly that I think it's an idea worthy of objective cost/benefit analysis rather than automatic dismal based on principle.
I agree that the potential for linting to prevent the problem is an important thing to consider when evaluating the cost/benefit of the change. The availability of reliable, lower-cost alternatives is a big factor in decision making. However, I'm unconvinced of the viability of linting to adequately address this problem; can it handle when the object's property isn't appearing directly inside the assertion and is instead extracted to a separate variable on a previous line or function? I disagree that this kind of change doesn't belong in Chai; I feel like it's appropriate to protect against developer mistakes as long as the cost isn't too steep (which it could be in this case).
While true, I don't like the role that this sentiment plays in decision making. It places a lot more value in general principle than specific cost/benefit. I think the feature being proposed here is similar in nature to the proxy protection we added in 4.x; the main difference is that this feature likely has lower benefit and higher cost than proxies. I think it's important we base our decision on those benefits and costs, as opposed to general principles (which could've been used to reject the proxy idea). As I said before, I'm pretty much sold on the benefit that this feature would provide, but I'm having problems regarding the cost as well as the viability of proposed linting alternatives, and thus haven't made up my mind yet. |
@meeber great arguments! Well, I am still not totally convinced this would be a great idea, but after reading your response to my comment I have a few more considerations to make. I agree this could benefit our users, but I still don't think it's worth it to create warnings and end up annoying others. I really like the idea of creating a configuration key for that, though, IMO that would be the optimal choice, since we would be able to keep both behaviors, educating developers that want explicit warnings and not annoying those who don't. Since this is a bit of a personal taste, I'd also like to hear our user's opinion, maybe with a bigger mass of inputs we can take the optimal decision here 😄 What do you guys think about opening a poll? |
Just in case this goes any further, I want to shout my objections for configuration options. I'm going to share some (perhaps tenuously related) sentiment with @meeber here. Testing is scary, and easy to get wrong, and we absolutely should, nay, must help developers where we (reasonably) can. Configuration options eschew this ideal, by making it harder for developers to get running with an optimal setup. I would prefer chai to be zero-configuration (the extreme of which, I believe that chai should only have the -- Okay back to the conversation at hand. This is why I am against this idea in general: ideally, I think developers should never have to refer to the docs for chai. Chai should just do the "Most Obvious Thing™". Proxies made this much closer to reality, because if I do something wrong (like Error: Invalid Chai property: undefine. Did you mean "undefined"? This is awesome because it means I spend less time trawling through docs, and chai is smart enough to set me right. We should definitely do this kind of stuff all the time. I really hope one day we get to be so friendly that our errors look something like... expect({ foo: 'bar' }).to.equal({ foo: 'baz' });
// Error: expected `{ foo: 'bar' }` to equal `{ foo: 'baz' }`
// they have different `foo` values the actual value of `foo`
// is 'bar' but the expected value of `foo` is 'baz'.
expect(someDate).to.equal(otherDate);
// Error: expected `someDate` to equal `otherDate`
// they have the same values but have different references.
// Consider `expect(someDate).to.deep.equal(otherDate)` instead. Rejecting expect(myObj.color).to.equal(otherObj.color);
// Error: expected `myObj.color` to equal `otherObj.color`
// they are the same value (`undefined`) but passing an object
// with a property as an expected value is incorrect.
// Consider instead writing `expect(myObj.color).to.equal(undefined)` Note that this would error the same if expect(myObj.color).to.equal(otherObj.color);
// Error: expected `myObj.color` to equal `otherObj.color`
// they are the same value (`false`) but passing an object
// with a property as an expected value is incorrect.
// Consider instead writing `expect(myObj.color).to.equal(false)` We can't do the above without static code analysis. @meeber is right though, we also can't rely on static analysis to catch every incantation; like this: const color = undefined
expect(myObj.color).to.equal(color);
// Is this valid? Or not? Why is this invalid/valid when other styles are invalid? My issue here is that adding the code to reject undefined values goes a tiny way to solving the real issue, and adds additional headaches for users when it suddenly no longer does "The Most Obvious Thing™". If I actually want to test an undefined value, I can no longer use So. Ultimately my thoughts are:
|
Thanks for the responses/discussion everyone! I definitely understand the philosophy of chai doing the "Most Obvious Thing" - without consulting documentation, it would indeed be my expectation that strictEqual does a === check, and only a === check. That kind of predictability/intuitiveness is a selling point of using chai for me. I also buy that this kind of problem is not something chai or any assertion library can solve completely, and it is definitely the case that developers should write tests defensively and much of the responsibility is with the developer to avoid the problems described so far. However, the argument that appeals to me the most is the cost-benefit one brought up by @meeber. I can provide a bit of anecdotal evidence from the codebase that I work on:
Of those 46 instances:
I fixed the assertions in the first group on a case-by-case basis (luckily none of these resulted in discovery of an actual production bug!). The second group is a codemod-able problem. My takeaway was that in our codebase there were no times when you actually want to compare that two things are simultaneously strictly equal and also undefined. Even if you could contrive a case where that might make sense, it doesn't seem particularly burdensome to just use assert.isUndefined twice. While there was some fixed cost of addressing existing offenses and a potential increased learning cost when using strictEqual, the benefit was high because it caught bad assertions that were giving us false confidence about the integrity of our code. |
@mlee thanks for the data! That's a great contribution to our discussion! 😄 I'm sorry, but I didn't fully understand your last paragraph, so, you mean you think the benefit of having to explicitly change those problematic assertions to make sure none of the inputs were actually undefined was high or did you mean that the benefit of adding warnings yourself was high? Given that data, (IMO) we have more solid arguments to avoid warnings, since more than 75% of the assertions were explicitly checking variables against It would be great to hear what other users have to say though and what the rest of the team thinks about this matter. PS.: I'm so proud to be part of such a great open source project and be able to discuss this subject with such dedicated and intelligent people. Thank you all ❤️ |
@lucasfcosta - sorry, I should have clarified exactly the steps I went through. I overrode the implementation of strictEqual to throw (not warn) when both the expected and actual parameters are undefined. I meant to say that in my specific case, given this change, I thought the benefit of reducing faulty assertions in our codebase was worth the cost of going through violations and fixing them one by one and the cost of other members of my team having to read up on strictEqual in the future if they are confused about why they can't use it with two undefined values. |
I've agonized over this one quite a bit. I think the cost/benefit is very close, and I could go either way. My current stance is that I support making Chai throw an error when
That last caveat is key because while I do like this idea, I don't like it enough to go to war over it. Therefore, I consider this post my closing arguments, and I'll go along with whatever the rest of the team decides. Here is a summary of my thoughts regarding the various arguments made against this idea:
|
@meeber rocks! Awesome explanation, as always! You are a professional problem solver with great analytical skills 😄 Well, considering each one of your points, these are my thoughts:
I totally agree that the message could be descriptive enough for our users to fix it right away, but understanding the why of this decision is kind of problematic. It could be easy to fix that, but I don't think many people will just do that blindly and then they'll look for the docs and they may even end up reading this issue where we discuss the pros and cons of this decision. I think an error message helps the user to fix that right away but I think they will annoy our users and even raise more questions in their minds.
I totally agree with you on this and perhaps the great majority of our userbase will never even see that this option exists. But the cost/benefit relation for implementing this is really high since it involves very little and safe changes to the code.
I'm not sure I fully understand this. You mean that the odds of someone making a mistake when writing the name of an object are very high and thus this would justify adding warnings? If so, this makes sense, but I still think it's not enough for us to add warnings and this is kind of related with the 8th item of this list.
Agreed 100%.
Agreed, but I don't think these are the majority of the cases our users will be dealing with, so I'd like to favor most of them by avoiding warnings that may be annoying.
I would be difficult indeed to write such a linting rule, but I think this is less "invasive" and more accurate.
This makes sense, there's nothing to add here.
I think the error message itself can be considered a surprise. If anyone migrates from After all, I don't disagree with any of your assumptions, I just see then in another way. Also, after reading all these points, adding a warning really seems like a good idea and I'd like to have it, I'm just not sure the cost/benefit relation of adding this would compensate for the change, so I'd go with the less risky but still somewhat effective option. Thank you for sharing your thoughts, you rock! 😄 |
@mlee There's some great discussion here, but I don't see enough support to continue pursuing this change. I'm gonna go ahead and close the issue. Thank you again for contributing! |
Current behavior:
Desired behavior:
If asserting on two undefined values, it is better to just use
chai.assert.isUndefined
. The problem with the current behavior is if the expected and actual arguments tostrictEqual
areundefined
by mistake, the assertion will still pass.A (contrived) example:
The text was updated successfully, but these errors were encountered: