0

While attempting to handle a situation where an update failed and I wanted to reset a button back to its original state, I discovered then(fn1,fn2) and then(fn1).catch(fn2) were giving different results after a rejected promise, despite the fact that they are supposed to be equivalent.

test('Reproduce error', () => {
    //setup jsdom
    document.body = document.createElement('body');
    let button = document.createElement('button');
    button.id = 'id';
    document.body.appendChild(button);

    //setup functions used
    let promiseReject;

    const updaterMock = function () {
        return new Promise((resolve,reject)=>{promiseReject = reject});
    };

    const markButtonOn = () => {};

    const markButtonOff = function () {
        console.log("I'm marking off");
        button.textContent = 'off';

        button.onclick = () => {
            button.textContent = 'updating';
            console.log("I'm updating");
            updaterMock()
                //using only then(): test passes
                .then(markButtonOn,markButtonOff);
                //using both then() and catch(): test fails on last expect()
                //.then(markButtonOn).catch(markButtonOff);
        };
    }

    //initial state
    markButtonOff();

    //always passes
    expect(button.textContent).toBe('off');

    //try to turn on
    button.click();

    //always passes
    expect(button.textContent).toBe('updating');

    //trigger the update to fail
    promiseReject();

    //force test to wait for the mock's returned promise to resolve
    return Promise.resolve().then(() => {
        //passes with .then(markButtonOn,markButtonOff)
        //fails with .then(markButtonOn).catch(markButtonOff), received 'updating'
        expect(button.textContent).toBe('off');
    });
});

I reduced the situation as much as I could, stripping out any unnecessary items. (At least, as best as I can tell. I'm still quite new to Javascript/Typescript. Sidenote: I got the promiseReject technique from here.)

If you change which line is commented out following the updaterMock() call in the button.onclick section in the middle, then the test result changes.

If I use just then(fn1,fn2), the test passes.

If I use then(fn1).catch(fn2) the test fails, saying the textContent is still in the 'updating' state.

In both cases, Jest reports that all 3 calls to console.log() go off:

I'm marking off

I'm updating

I'm marking off

Which tells me the 2nd call to markButtonOff is executing, but Jest isn't seeing the element's change.

  1. Why are then(fn1,fn2) and then(fn1).catch(fn2) behaving differently?

  2. How can I change things in my test to fix this (ideally, just doing something to the test code, not to the markButtonOff production code, which I wrote straight into the test to make it easy for others to reproduce.)

Willem Renzema
  • 5,111
  • 1
  • 16
  • 24
  • 2
    No, `promise.then(fn1, fn2)` is not necessarily the same as `promise.then(fn1).catch(fn2)` - in the case when `fn1` throws an error with the first invocation `fn2` would not be called to react to it - it will only be called if `promise` rejects. In the second case `fn2` will be called if either `promise` or `fn1` rejects the chain. Just because `catch(f)` is equivalent to `.then(undefined, f)` doesn't mean it's the same as combining the two. – VLAZ Dec 10 '21 at 16:59
  • With that said, I suspect the problem is `//force test to wait for the mock's returned promise to resolve` - you shouldn't expect two unrelated promise chains to be resolved in any specific order. The order is likely going to be *consistent* between different runs just generally unpredictable. So, you're not *guaranteed* that the button promise chain is finished before the promise chain at the end of the test. – VLAZ Dec 10 '21 at 17:00
  • @VLAZ `fn1` isn't even called, so I didn't consider that situation relevant to the situation, as only the rejection path occurs here. As for the end part, that was the best solution I could figure based out based on the examples in the Jest docs. What would be a better way to handle it? – Willem Renzema Dec 10 '21 at 17:29
  • Save the promise from the `onclick` - do `p = updaterMock().then(markButtonOn).catch(markButtonOff)` then at the end of the test do `p.then()` instead of `Promise.resolve().then()` - this would guarantee that the assertion runs after the promise chain resolves. Hopefully, getting the timing right is enough to get the test to pass. If not, at least the test is entirely predictable now. – VLAZ Dec 10 '21 at 17:31
  • @VLAZ Unfortunately that would involve trying to save a result from inside the production code to use in a test. Even in this simplified example I don't see it working as `p` would is scoped inside a block, so isn't accessible at the end of the test, outside that block. – Willem Renzema Dec 10 '21 at 17:39
  • You already have all this code in a test, right? This wouldn't change anything. You're already calling `updateMock()`, so you're supplying some custom test code - whether or not you expose some of the inner works seems irrelevant here. You're already doing the same with `promiseReject`. I don't see how exposing the promise chain from inside your test code is any different. If you don't want to do it, you have to find out some other way to find if the promise chain finished. You can `setTimeout(assertions, 0)` but it's getting sketchy. – VLAZ Dec 10 '21 at 17:44
  • I forgot I could just `let p` outside that block. Like I said, I'm new to JS. However, it still doesn't really help me in my real case situation. I want to be able to test my production code. I'm injecting `updaterMock()`, which makes the API call to the server, into the class that has `markButtonOn`, but I would have to expose more of `markButtonOn`'s class just for testing using your solution. I may have oversimplified the situation. I thought mocks and injection would be common practice, so didn't think I had to clarify that. My bad. In any case, your responses helped a lot, thank you. – Willem Renzema Dec 10 '21 at 18:00
  • If you have no way of reacting to the change of the element, you could at least do a small polling loop - check every 50ms or so whether it has changed and if it assumes the state you expect then you're done. If it hasn't for some arbitrary time limit, say, 2000ms, then stop and assume it failed. it's more brittle this way but doesn't depend on the more broken timings of promise chains, nor does it depend on injected code. – VLAZ Dec 10 '21 at 18:32
  • Maybe a dup and definitely a useful read: https://stackoverflow.com/questions/33445415/javascript-promises-reject-vs-throw – danh Dec 11 '21 at 05:32
  • "*the fact that they are supposed to be equivalent.*" - I don't see where the MDN page you linked says that – Bergi Dec 13 '21 at 03:58
  • @Bergi It's literally the 2nd sentence. – Willem Renzema Dec 13 '21 at 04:06
  • How is this a duplicate of the linked question? There's useful information there, but my problem was the result of a misunderstanding of what was happening due to a race condition. And it certainly doesn't have any useful information on how to make a Jest test wait for a promise chain to complete. – Willem Renzema Dec 13 '21 at 04:11
  • @WillemRenzema `obj.catch(onRejected)` being equivalent to `obj.then(undefined, onRejected)` means that `.then(fn1).catch(fn2)` is equivalent to `.then(fn1).then(undefined, fn2)`, not to `.then(fn1,fn2)`. – Bergi Dec 13 '21 at 04:48
  • @WillemRenzema Sorry for closing as a duplicate, it seemed that "*Why are then(fn1,fn2) and then(fn1).catch(fn2) behaving differently?*" was your main question, which is answered by the canonical question. If you have multiple questions, best ask them separately. – Bergi Dec 13 '21 at 04:50

1 Answers1

0

VLAZ correctly identified my core issue in that two promise chains will not predictably resolve in the same order.

The fact that I got different results between then(fn1,fn2) and then(fn1).catch(fn2) after a rejected promise was pure luck/randomness/non-deterministic garbage.

After discussion I understood the issue better and redesigned the test to have a continuous promise chain from start to finish.

test('Reproduce error', () => {
    //setup jsdom
    document.body = document.createElement('body');
    let button = document.createElement('button');
    button.id = 'id';
    document.body.appendChild(button);

    //setup functions used
    let startPromiseChain;

    let p = new Promise((resolve, reject) => {
        console.log('A');
        startPromiseChain = resolve;
    }).then(() => {
        //verify the initial state after the click() is to be 'updating'
        //and then reject() to trigger the catch() handler in markButtonOff
        console.log('L');
        expect(button.textContent).toBe('updating');
        return Promise.reject();
    });

    //"injected" mock
    console.log('B');
    const updaterMock = function () {
        return p;
    };

    //** begin production code
    console.log('C');
    const markButtonOn = () => {};

    //added a finally() call to the promise chain to be able to inform the
    //test that the promise has finished resolving inside the production code
    const markButtonOff = function (finallyCallback) {
        console.log('F/M');
        button.textContent = 'off';

        button.onclick = () => {
            console.log('I');
            button.textContent = 'updating';
            updaterMock()
                //using only then(): test passes
                //.then(markButtonOn,markButtonOff).finally(finallyCallback);
                //using both then() and catch(): test passes as well
                .then(markButtonOn).catch(markButtonOff).finally(finallyCallback);
        };
    }

    //** end production code

    //function to start the 2nd promise chain in the test (below), once the
    //promise chain inside markButtonOff finishes
    let callOnFinally;

    //create a promise that resolves after markButtonOff finishes updating
    let q = new Promise((resolve,reject) => {
        console.log('D');
        callOnFinally = resolve;
    }).then(() => {
        //make sure the button says it is 'off' again after it finishes updating
        console.log('N');
        expect(button.textContent).toBe('off');
    });

    //setup the initial button state
    console.log('E');
    markButtonOff(callOnFinally);

    //assert button is initially off
    console.log('G');
    expect(button.textContent).toBe('off');

    //try to turn on
    console.log('H');
    button.click();

    //trigger the update to fail, simulating an API request failure
    //this starts the first promise in the test to resolve
    console.log('J');
    startPromiseChain();

    console.log('K');
    //return the promise created above so Jest waits for it to resolve
    return q;
});

All the console.log calls are in correct alphabetical order for what is output when I run the test.

Here is a summary of the process:

  1. Create a promise we can manually activate to kick the whole thing off, and include a .then() that both checks the initial state after the promise chain starts, and that actually triggers the reject path that we want to test.

  2. Setup the production code. Here I added a callback function to be called in a finally() after the then(fn1,fn2) or then(fn1).catch(fn2). This is necessary (as far as I can tell), and is an acceptable compromise, as that callback could also be used for other things that want to wait for the button to finish updating.

  3. Setup another promise that can be triggered via that finally() callback, and have that do the final assertions of the test, to make sure everything is how it should be when the production code has finished.

  4. Start the promise chain in step 1 and return the promise from step 3 from the test, so that Jest waits for the whole chain to complete before it reports pass or fail.

It looks a little convoluted due to the need to define and assign variables in the right order, but it works perfectly and makes logical sense. (Unless I made a mistake and it is just working by luck, but I think I understand it now.)

Willem Renzema
  • 5,111
  • 1
  • 16
  • 24