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

Jest does not allow asynchronous catching of rejected promises #6028

Closed
MikeyBurkman opened this issue Apr 18, 2018 · 11 comments
Closed

Jest does not allow asynchronous catching of rejected promises #6028

MikeyBurkman opened this issue Apr 18, 2018 · 11 comments

Comments

@MikeyBurkman
Copy link

MikeyBurkman commented Apr 18, 2018

Bug
Jest version: 22.4.3 (But was introduced in 21.x)
Config: None (all defaults)
Node version: 6.11.5, 8.9.4

Jest does not allow you to asynchronously attach a .catch() to a promise, even if it's added before the test itself is finished. The only difference between these two tests is that the catch is added within a setTimeout() in the second one.

// This passes after ~107ms (the expected amount of time)
test('Synchronous catching of promise', (done) => {
    const promise = Promise.reject(new Error('nope'));

    promise.catch((err) => console.log('Caught error: ', err.message));
    
    // At a later time, finish the test
    setTimeout(() => {
        console.log('here');
        done();
    }, 100);
});

// This fails after ~10ms, with error message "nope"
test('Async catching of promise', (done) => {
    const promise = Promise.reject(new Error('nope'));

    setTimeout(() => {
        promise.catch((err) => console.log('Caught error: ', err.message));
    });

    // At a later time (well after the catch() has been attached to the promise), finish the test
    setTimeout(() => {
        // We nver makes it to this point because Jest sees that promise was not caught immediately
        console.log('here');
        done();
    }, 100);
});

screen shot 2018-04-18 at 4 40 26 pm

In Jest 20.x, only warnings are printed, and the test does not fail

(node:54613) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 2): Error: nope
(node:54613) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 2)

(Was originally mentioned in #3251)

@MikeyBurkman MikeyBurkman changed the title Jest does not allow asynchronous catching of promises Jest does not allow asynchronous catching of rejected promises Apr 18, 2018
@SimenB
Copy link
Member

SimenB commented Apr 19, 2018

Duplicate of #5311 (although this has a better description 🙂)

@SimenB SimenB closed this as completed Apr 19, 2018
@grantila
Copy link

@SimenB this is not a duplicate. #5311 is about a warning and what looks to me like a misunderstanding. In @gaearon's code, expect() will throw out of the test and leave promise dangling, hence garbage collected and warned about it not being caught. That's actually good.

This issue is a serious bug, it means one can't write code which asynchronously catches promise rejections. This is entirely unexpected.

@grantila
Copy link

Going back to jest 20 from 24 solves this, so it's obviously a regression.

@SimenB
Copy link
Member

SimenB commented Jun 28, 2019

It has the same underlying cause - we added our own 'unhandled rejection' handler. The change is a good one, as a random unhandled promise will fail your test (like a sync error) instead of either being swallowed or just printed as a warning. I think it's way more more often the case you want to fail tests on unhandled promise rejections than testing handlers. However, I think we should allow disabling it on a test by test basis if you explicitly want to test rejection handlers.

I think that can be tracked in #5311, or do you think it's orthogonal?

@DanielSWolf
Copy link

DanielSWolf commented Dec 19, 2019

I'm having the same problem. Consider the following test:

it('terminates the test prematurely', async () => {
  const promise = new Promise((resolve, reject) => setTimeout(() => reject(new Error('Oops')), 0));
  await new Promise(resolve => setTimeout(resolve, 10));
  await expect(promise).rejects.toThrow('Oops');
});

This creates a promise, does some asynchronous stuff, then expects that the promise fails. But execution never reaches the assertion. As soon as the promise rejects (while waiting for the timeout), the unit test is terminated and marked as failing.

I understand that #5311 has a similar cause, but I believe these are two different issues. My understanding is that #5311 describes the case that at the end of the test, promises are still dangling. This should be avoided. This issue, in contrast, describes the case that during test execution, a promise is not immediately subscribed to. This, in my opinion, is a perfectly valid scenario that should work.

@DanielSWolf
Copy link

What's more, the code may not even be part of the test suite, but of the system under test. Consider:

code-under-test.js

export async function codeUnderTest() {
  const promise = new Promise((resolve, reject) => setTimeout(() => reject(new Error('Oops')), 0));
  await new Promise(resolve => setTimeout(resolve, 10));
  return promise;
}

test.js

import { codeUnderTest } from '../src/code-under-test.js';

describe('codeUnderTest', () => {
  it('terminates the test prematurely', async () => {
    await expect(codeUnderTest()).rejects.toThrow('Oops');
  });
});

The function codeUnderTest is perfectly valid, yet fails any unit test that attempts to call it. Is there any workaround?

@srli
Copy link

srli commented Dec 19, 2019

After much frustration, we were able to work around this issue by adding a dummy catch to the promise.

it('terminates the test prematurely', async () => {
  const promise = new Promise((resolve, reject) => setTimeout(() => reject(new Error('Oops')), 0));
  promise.catch(err => { return; });
  await new Promise(resolve => setTimeout(resolve, 10));
  await expect(promise).rejects.toThrow('Oops');
});

This will not solve the issue in an imported function, but should at least band-aid the problem in tests.

@DanielSWolf
Copy link

Nice idea! I reworked it into a defuse function:

function defuse(promise) {
  promise.catch(() => {});
  return promise;
}

it('terminates the test prematurely', async () => {
  const promise = defuse(new Promise((resolve, reject) => setTimeout(() => reject(new Error('Oops')), 0)));
  await new Promise(resolve => setTimeout(resolve, 10));
  await expect(promise).rejects.toThrow('Oops');
});

Still, this is a workaround for a problem that I believe should be fixed.

@Rexman17
Copy link

Nice idea! I reworked it into a defuse function:

function defuse(promise) {
  promise.catch(() => {});
  return promise;
}

it('terminates the test prematurely', async () => {
  const promise = defuse(new Promise((resolve, reject) => setTimeout(() => reject(new Error('Oops')), 0)));
  await new Promise(resolve => setTimeout(resolve, 10));
  await expect(promise).rejects.toThrow('Oops');
});

Still, this is a workaround for a problem that I believe should be fixed.

Hi @DanielSWolf would you mind explaining how/why this fixes the issue? I just wrote a defuse function of my own which is ridding me of the Warning but I don't understand why.

@DanielSWolf
Copy link

DanielSWolf commented Mar 25, 2021

@Rexman17 Let's walk through the function defuse.

promise.catch(() => {});

This line creates a new promise that catches and swallows any rejection (error) by the original promise. If the original promise succeeds with a value, the new promise succeeds with the same value. If the original promise fails, the new promise still succeeds, but with undefined as value.

But notice that this isn't what we return:

return promise;

What we return, instead, is the original promise. This means that any code using the "defused" Promise will behave exactly as if it were using the original promise. After all, it is the same promise. The only difference is that after the call to defuse, the original promise has a continuation. But a promise can have any number of continuations (.then(...), .catch(...)) attached to it, and there is no official way to access a promise's continuations, so there is no observable difference between the promise before and after defusion.

Only that, apparently, Jest does some cheating and checks whether every promise has a .catch continuation when it fails. (I didn't check the source code to see how they do that.) And when Jest does that, it finds that the promise does indeed have a .catch continuation, so it's happy and doesn't bother us with its error heuristic.

@github-actions
Copy link

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators May 10, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants