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

toWarnInDev matcher; throw on unexpected console.error #11786

Merged
merged 19 commits into from
Jan 2, 2018

Conversation

bvaughn
Copy link
Contributor

@bvaughn bvaughn commented Dec 6, 2017

Relates to #11626.

Added a new matcher for dev errors and warnings. Rather than checking for warnings like this:

spyOnDev(console, 'error');

// Some code

if (__DEV__) {
  expect(console.error.calls.count()).toBe(2);
  expect(console.error.calls.argsFor(0)[0]).toContain('Some error');
  expect(console.error.calls.argsFor(1)[0]).toContain('Some other error');
}

You can now write this:

expect(() => {
  // Some code
}).toWarnDev([
  'Some error',
  'Some other error',
]);

In addition to being syntactically shorter, I've also changed our default console overrides to report the correct call-stack for unexpected warnings. The combination of these two changes provides a couple of benefits:

  • Warnings being verified are now explicitly scoped to a block of code.
  • Any warnings that happen outside of this scope are guaranteed to fail the test, unlike before where a forgotten assertion on calls.count() could result in suppressed/ignored warnings.
  • Unexpected warnings show the correct call stack (making them easier to identify and correct or add tests for).
  • Error stack normalization logic is now centralized (rather than copy-pasting it around each test that depends on it).

I've updated a dozen or so tests to use the new matcher, to make sure it works correctly. There are still several more with console spies that I can remove via follow-up PRs.

Follow Up Work

Expected failures

Unexpected warnings

it('should fail for unexpected warnings(s)', () => {
  const foo = () => bar();
  const bar = () => baz();
  const baz = () => console.error('unexpected warning');

  foo();
});

screen shot 2017-12-19 at 3 06 37 pm

Unexpected warnings within a try/catch

it('should fail for unexpected warnings(s) within a try/catch', () => {
  const foo = () => bar();
  const bar = () => baz();
  const baz = () => console.error('unexpected warning');

  try {
    foo();
    console.error('another unexpected warning');
  } catch (err) {}
});

screen shot 2017-12-19 at 3 07 13 pm

Errors thrown within toWarnDev

it('should fail for errors thrown within .toWarnDev()', () => {
  expect(() => {
    asdf();
  }).toWarnDev('foo')
});

screen shot 2017-12-19 at 3 08 20 pm

Invalid toThrow within toWarnDev

it('should fail .toThrow() within .toWarnDev()', () => {
  expect(() => {
    console.error('foo');
    expect(() => {}).toThrow();
  }).toWarnDev('foo');
});

screen shot 2017-12-19 at 3 09 51 pm

Passing toWarnDev within toThrow

it('should fail .toWarnDev() within .toThrow()', () => {
  expect(() => {
    expect(() => console.error('foo')).toWarnDev('foo')
  }).toThrow();
});

screen shot 2017-12-19 at 3 10 40 pm

Incorrect/non-matching warning

it('should fail when the expected warnings passed to .toWarnDev() do not match the actual warnings', () => {
  expect(() => {
    console.error('foo');
  }).toWarnDev('bar');
});

screen shot 2018-01-02 at 10 58 04 am

Passing tests

Nested toThrow within toWarnDev

it('should pass .toThrow() within .toWarnDev()', () => {
  expect(() => {
    console.error('foo');
    expect(() => asdf()).toThrow();
  }).toWarnDev('foo');
});

Nested toWarnDev within toThrow

it('should pass .toWarnDev() within .toThrow()', () => {
  expect(() => {
    expect(() => {}).toWarnDev('foo')
  }).toThrow();
});

Open Questions

Nested warnings

expect(() => {
  console.error('foo');
  expect(() => {
    console.error('baz');
    console.error('qux');
  }).toWarnDev([
    'baz',
    'qux',
  ]);
  console.error('bar');
}).toWarnDev([
  'foo',
  'bar',
]);

This is currently supported, although it's unclear if this is a case we would ever actually need or want to use. Once we've migrated the current warning tests over to use this new matcher, if we determine that we don't need to support nesting, we can change it to fail explicitly.

if (__DEV__) {
if (typeof expectedWarnings === 'string') {
expectedWarnings = [expectedWarnings];
} else if (!Array.isArray(expectedWarnings)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@bvaughn Are you planning on using Flow inside tests at some point? If so, you could get rid of these checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True.

And no idea. 😄

]);

// Should not log any additional warnings
expect(() => React.createElement('div')).toWarnDev([]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this necessary? Shouldn’t an unexpected log fail the test anyway? I expect that if we explicitly mark each warning block then any warning outside should be a failure. Maybe even immediately when it’s called rather than at the end of the test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Shouldn’t an unexpected log fail the test anyway

Good point.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now that I look at this again, I believe this explicit check is necessary since we've necessarily spied on console.error in order for the matcher to work.

IIR, I originally tried to clear the spy at the end of the matcher, so unexpected errors/warnings would cause an error, but neither the version of Jest we're using (21.3.0-beta.4) nor the latest release (22) actually expose the spy.mockReset method the docs mention.

We could call spy.and.callThrough() at the bounds of the matcher, so that errors were actually passed through, but (a) that could negatively affect tests with multiple .toWarnDev calls and (b) this wouldn't actually fail the test, only print the red text to the console.

Maybe I'm overlooking something?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Now that I look at this again, I believe this explicit check is necessary since we've necessarily spied on console.error in order for the matcher to work.

Wouldn't this approach solve it? #11786 (comment)

IIR, I originally tried to clear the spy at the end of the matcher, so unexpected errors/warnings would cause an error

I don't quite understand what clearing means in this context, or why it would be helpful.

My proposal would be to set up a mock when we enter toWarn and tear it down when we exit it. Outside of toWarn, I'd want console.error to be replaced with a function that insta-throws.

Copy link
Contributor Author

@bvaughn bvaughn Dec 18, 2017

Choose a reason for hiding this comment

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

I think we're saying the same thing, but misunderstanding each other.

My initial design for this matcher was to setup a spy/mock when entering and then remove it before exiting. This way unexpected console.error calls would fail the test (as they do if you don't spy in the first place). The Jest docs suggest this should be possible via a mockReset method, but that method doesn't actually exist (in Jest 21 or 22) so I'm not sure why the docs mention it. The problem then, is that once we've spied on console.error initially, we can't remove that spy and so any unexpected calls to it don't actually cause failures.

We could work around this in other ways. For example, I could not use Jest's spyOn functionality at all- but just override console.error with my own function and then restore the originally one before exiting. Maybe that's best anyway?

Copy link
Collaborator

Choose a reason for hiding this comment

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

(My initial comment didn't make sense so I edited it)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah! I think I understand what you're saying better now.

The boundary is clearer with the new API. Before the boundary was more informal, between spyOn and the first (...calls.length).toBe() check.

It was still possible to do the throw-immediate thing before though. That's what confused me a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess it was also easier for the old spies to miss things if warnings were logged after the last count check too. That's another advantage of this boundary.

Copy link
Collaborator

@gaearon gaearon Dec 18, 2017

Choose a reason for hiding this comment

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

It was still possible to do the throw-immediate thing before though

How would it work with this test case?

spyOnDev(console, 'error')

console.error('unexpected warning')
console.error('expected warning')

expect(console.error.calls.count).toBe(1)
expect(console.error.calls.argsFor(0)[0]).toBe('expected warning')

Previously we couldn't safely throw on the first console.error because we didn't know its message is unexpected by the time it ran.

But with toWarn we can because we know the exhaustive list of expected messages (and their count) before console.error is called.

Thus we'll get a full stack trace to the place where the message actually fired, as opposed to our check. And less code would run (which aids debugging).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It just occurred to me coming back from lunch that, since I know the error message(s) ahead of time, I could also throw immediately (within my fake spy) any time a message is logged that isn't expected.

I'll rework the matcher to behave this way instead.

@@ -317,6 +270,8 @@ describe('ReactElementValidator', () => {
'or a class/function (for composite components) but got: null.' +
(__DEV__ ? '\n\nCheck the render method of `ParentComp`.' : ''),
);

// We can't use .toWarnDev() here because we can't chain it with .toThrowError()
if (__DEV__) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let’s bite the bullet and nest assertions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure what you have in mind here. Mind giving a faux code example?

Copy link
Collaborator

Choose a reason for hiding this comment

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

expect(() => {
  expect(() => {
  
  }).toWarnInDev()
}).toThrowError('message')

Although there's a hazard of not specifying the message and thus catching a failure from the inner expect.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe nesting them the other way around would work.

Copy link
Contributor Author

@bvaughn bvaughn Dec 18, 2017

Choose a reason for hiding this comment

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

Nesting toWarnDev inside of toThrowError won't work. Failures in the toWarnDev assertion are silent. But nesting it the other way around (toThrowError inside) does seem to work.

]);

// Should not error for strings
expect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same; I would expect any warning that isn’t inside a protected block to fail immediately.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this was just me being silly.

Copy link
Collaborator

Choose a reason for hiding this comment

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

To be clear, I expect that with current infra it fails at the end of the test.

However, I propose that toWarnInDev acts as a boundary, and we change console.error/console.warn to immediately throw outside of that boundary. This way we get the error immediately at the right call site instead of at the end of the test.

@bvaughn bvaughn changed the title [WIP] Added toWarnInDev matcher and updated one test to use it [WIP] Added toWarnInDev matcher; throw on console.error/warning call Dec 18, 2017
@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 19, 2017

I've refactored this to fail faster for unexpected warnings. I also added some syntax snippets to the description.

Still need to look into the following:

  • Do we actually need to support nested warnings? (Probably not.) If not, then we can just error on this case.
  • Is there a way to get a better error stack for unexpected warnings? Looks like Jest is catching truncating it to just the test where the expectation is defined. I could add the stack to the thrown error message but that seems a little hacky.

@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 19, 2017

It looks like the stack thing I mentioned is intentional. Jest overrides the "real" stack with one that points to the matcher.

I've posted to the internal Jest group about this and will follow up about it later. For now, I'll just leave a TODO comment.

@bvaughn bvaughn changed the title [WIP] Added toWarnInDev matcher; throw on console.error/warning call Added toWarnInDev matcher; throw on console.error/warning call Dec 19, 2017
@bvaughn bvaughn changed the title Added toWarnInDev matcher; throw on console.error/warning call toWarnInDev matcher; throw on unexpected console.error Dec 19, 2017
@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 19, 2017

I filed a Jest issue (jestjs/jest/issues/5136) and PR (jestjs/jest/pull/5138) for preserving the call stack for errors inside of a custom matcher. I don't think either is a blocker for moving forward with this issue though, so back to you @gaearon 😄

@gaearon
Copy link
Collaborator

gaearon commented Dec 19, 2017

What did we decide about throwing?

We should make sure this fails the test:

try {
  console.error('hi');
} catch (err) { }

@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 19, 2017

That's a good point, Dan. Using the afterEach to throw, like we did before, obscured the call stack. Throwing synchronously might get swallowed up and suppressed though.

I think the best compromise might be to track call stacks during execution, save them for later, then throw a single error from the afterEach block. This would not prevent the test from continuing to execute (unfortunately, I don't know a way to reliably do that) but it would hopefully simplify the debugging/test-authoring experience.

Maybe I'm overdoing this, but here's a compromise that I think might work? For example, this test:

describe('Example', () => {
  it('should warn about an unexpected log', () => {
    const foo = () => bar();
    const bar = () => baz();
    const baz = () => console.error('unexpected warning');

    foo();
  })

  it('should warn about two unexpected logs', () => {
    const foo = () => bar();
    const bar = () => baz();
    const baz = () => console.error('unexpected warning');

    try {
      foo();
      console.error('another unexpected warning')
    } catch (err) { }
  })
});

Would print the following failures:
screen shot 2017-12-19 at 2 47 36 pm

@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 19, 2017

I've pushed a fix for this (as described above) and updated the description of this PR for the various cases I've tested (locally) with this matcher.

@gaearon
Copy link
Collaborator

gaearon commented Dec 19, 2017

I think this makes sense. I'll review this tomorrow.

@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 21, 2017

Friendly ping 😄

@bvaughn
Copy link
Contributor Author

bvaughn commented Dec 31, 2017

Another friendly ping

@gaearon
Copy link
Collaborator

gaearon commented Jan 2, 2018

Sorry, I was on holidays!

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 2, 2018

Of course! 😄 Was just a reminder for when we both came back.

@DavidCurtis321
Copy link

DavidCurtis321 commented Jan 2, 2018 via email

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 2, 2018

Thanks, but I don't need Outlook 😉

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

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

LG to me

}

// Fail early for unexpected warnings to preserve the call stack.
throw Error(`Unexpected warning recorded: "${message}"`);
Copy link
Collaborator

@gaearon gaearon Jan 2, 2018

Choose a reason for hiding this comment

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

Can we include the expected next warning message if one exists, in this message?

Otherwise it's a bit confusing that simply having a typo in the expected warning prints a message as if a warning was not expected at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. I like this suggestion.

What do you think about the following change?
screen shot 2018-01-02 at 10 50 01 am

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be clearer:

it('should fail when the expected warnings passed to .toWarnDev() do not match the actual warnings', () => {
  expect(() => {
    console.error('foo');
  }).toWarnDev('bar');
});

screen shot 2018-01-02 at 10 58 04 am

console[methodName] = newMethod;

env.beforeEach(() => {
newMethod.__callCount = 0;
unexpectedConsoleCallStacks.splice(0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I always forget what this does. Maybe = [] or .length = 0?

@gaearon
Copy link
Collaborator

gaearon commented Jan 2, 2018

How do you feel about converting other tests as part of this PR? I don't care strongly either way.

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 2, 2018

I could do that, although this PR is already pretty big. I stopped converting because I was getting increasing concerned about merge conflicts. 😄 I'd be happy to do a follow up PR or two quickly after merging this.

I'm willing to do it all as part of this PR too though if you have a preference.

@gaearon
Copy link
Collaborator

gaearon commented Jan 2, 2018

Let's split (as long as both styles keep working and there's no regressions).
But we'll have merge conflicts anyway (since it's likely your PRs will land before any others).

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 2, 2018

True. I was mostly worried about having to deal with merge conflicts myself 😅 I'll cause potential conflicts for others no matter what I do.

I've merged master into this branch and addressed your 2 comments above. Once CI passes, I'll merge this and then continue updating other tests with a follow-up PR.

@gaearon
Copy link
Collaborator

gaearon commented Jan 2, 2018

As a stretch goal it’d be nice to highlight the text difference on a character level. I often make small tweaks and then it’s super annoying to stare into the message trying to figure out where exactly the different lies (and which one is right).

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 2, 2018

Yeah, I briefly considered doing this, using jest-diff, but the differences are too big. For examples, there's a "Warning:" prefix that may not be there, and there's the component stack that may be different - neither of which are actually important in the context of the test in many cases.

Maybe we could do some kind of minimum-edit-distance calculation to narrow down the area to diff?

Edit: I think jest-diff just isn't the right tool for this and I was over-complicating things. Let me think on this a bit more. I think it should be doable (via a follow-up PR).

@bvaughn bvaughn merged commit b5334a4 into facebook:master Jan 2, 2018
@bvaughn bvaughn deleted the expect-toWarnInDev branch January 2, 2018 19:06
yenshih pushed a commit to yenshih/react that referenced this pull request Jan 6, 2018
* Added toWarnInDev matcher and connected to 1 test
* Added .toLowPriorityWarnDev() matcher
* Reply Jest spy with custom spy. Unregister spy after toWarnDev() so unexpected console.error/warn calls will fail tests.
* console warn/error throws immediately in tests by default (if not spied on)
* Pass-thru console message before erroring to make it easier to identify
* More robustly handle unexpected warnings within try/catch
* Error message includes remaining expected warnings in addition to unexpected warning
bvaughn added a commit to bvaughn/react that referenced this pull request Jan 23, 2018
While writing tests for unsafe async warnings, I noticed that in certain cases, errors were swallowed by the toWarnDev matcher and resulted in confusing test failures. For example, if an error prevented the code being tested from logging an expected warning- the test would fail saying that the warning hadn't been logged rather than reporting the unexpected error. I think a better approach for this is to always treat caught errors as the highest-priority reason for failing a test.

I reran all of the test cases for this matcher that I originally ran with PR facebook#11786 and ensured they all still pass.
bvaughn added a commit that referenced this pull request Jan 23, 2018
While writing tests for unsafe async warnings, I noticed that in certain cases, errors were swallowed by the toWarnDev matcher and resulted in confusing test failures. For example, if an error prevented the code being tested from logging an expected warning- the test would fail saying that the warning hadn't been logged rather than reporting the unexpected error. I think a better approach for this is to always treat caught errors as the highest-priority reason for failing a test.

I reran all of the test cases for this matcher that I originally ran with PR #11786 and ensured they all still pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants