Skip to content

Instantly share code, notes, and snippets.

@asiellb
Forked from quasicomputational/faking-promises.md
Created February 25, 2019 18:59
Show Gist options
  • Save asiellb/01e0d885c065bbc2dcc8f820fa87eff3 to your computer and use it in GitHub Desktop.
Save asiellb/01e0d885c065bbc2dcc8f820fa87eff3 to your computer and use it in GitHub Desktop.

Being able to flush promise resolution (or rejection) in tests is really, really handy, and even essential sometimes. Jest has an open issue for this but I'm impatient.

Setting this up in userland is possible but non-trivial - an adventure, even. I'll lay out what I had to do for any future intrepid types. I'll try to explain the reasoning for all of this and have nothing be magical.

The over-all target is to do (micro-)task scheduling entirely in userland so that the task queue can be synchronously run to exhaustion. This entails faking timers and swapping out the native promise implementation for one that'll use the faked timers. All of this will be assuming you're using Jest, but the general ideas are test library agnostic.

Runtime performance seems near to native, though there's significantly more transpilation to be done - first runs will be much slower. If you're seeing significant slowdowns, something's probably misconfigured - I was seeing tests running two orders of magnitude slower (!) for somewhat mysterious reasons, but I was improperly mixing native promises and Bluebird and the slowdown went away once I fixed that.

async functions unavoidably use native timers, so we will have to transpile them - including in node_modules. Hence, you will need to use babel.config.js, not .babelrc.

You'll need two plugins to cover all of ES2018's async syntax: @babel/plugin-proposal-async-generator-functions and @babel/plugin-transform-async-to-generator. Those will compile it down to expressions that use the global Promise binding.

All told, here's a babel.config.js that does what I need it to:

module.exports = (api) => {
    api.cache(true);
    return {
        "presets": [
            [
                "@babel/env",
                {
                    "targets": {
                        "browsers": [
                            "node 11",
                        ],
                    },
                },
            ],
        ],
        "env": {
            "production": {},
            "test": {
                "plugins": [
                    "@babel/plugin-proposal-async-generator-functions",
                    "@babel/plugin-transform-async-to-generator",
                ],
            },
        },
    };
};

Next, faking timers. lolex's implementation is more complete than Jest's own. There's a Jest PR to integrate lolex that will streamline this, but it's not too painful to use them directly, though there is some subtlety.

Because the whole point of this is for tests to be able to synchronously flush the task queue, the lolex instance is bound to the global variable clock.

JSDOM doesn't provide queueMicrotask, but that's precisely the primitive Bluebird needs to schedule promises correctly. Lolex will only fake timer functions that it sees in the environment. As JSDOM is the default environment in Jest, this means that we need to tell lolex explicitly to fake queueMicrotask. Note that lolex also doesn't fake process.nextTick by default, so if your code ever invokes that (as mine does), you'll also need that on the list. I left out Date in the list of things to fake, but lolex is fully capable of faking that too.

I also found lolex's limit of 1000 timer schedules too low, so I bumped it up to 50,000.

This file, I called test-util/fake-timer-environment.js.

const lolex = require("lolex");
const JSDOMEnvironment = require("jest-environment-jsdom");

class FakeTimerEnvironment extends JSDOMEnvironment {

    async setup() {
        await super.setup();
        // TODO: just use jest's lolex integration once that lands - https://github.com/facebook/jest/pull/7776 is the current PR.
        // We need nextTick for fake-indexeddb, so explicitly name everything. And we also need to be able to call clock.runAll from inside tests for testing specific concurrency orderings, so make it global.
        this.global.clock = lolex.install({
            toFake: ["setTimeout", "clearTimeout", "setImmediate", "clearImmediate","setInterval", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame", "requestIdleCallback", "cancelIdleCallback", "hrtime", "nextTick", "queueMicrotask"],
            loopLimit: 50000,
            target: this.global,
        });
    }
}

module.exports = FakeTimerEnvironment;

For some reason that I don't understand, I can't just write this.global.Promise = bluebird in the environment and have that work - it gets replaced by native promises. So, we'll have to swap out the promises by mucking with globa.Promise. Again for a reason I don't understand, this doesn't work if it's done in a setup file (neither setupFiles or setupFilesAfterEnv) - it has to be in the main body of the test's execution. More investigation is needed.

Bluebird's default behaviour with unhandled rejections is to log them. In tests, I want any unhandled rejections to fail the test, rather than letting it pass, but handily Bluebird gives us the exact tool we need to do that.

So - call it test-util/shim-promise.mjs:

import bluebird from "bluebird";

bluebird.onPossiblyUnhandledRejection((err) => {
    throw err;
});

bluebird.setScheduler(queueMicrotask);

global.Promise = bluebird;

As for the tests themselves, you'll need the first import to be of that test-util/shim-promise.mjs module, so that all subsequent imported modules get Bluebird's promises from the get-go.

I also defined a couple of helper functions:

export const beforeEachAsync = (fn) => {
    beforeEach(() => {
        fn();
        clock.runAll();
    });
};

export const itAsync = (name, fn) => {
    it(name, () => {
        fn();
        clock.runAll();
    });
};

This lets me write my tests with async/await, and then instead of it I use itAsync and I get the task queue exhausted and any unhandled rejections are discovered and synchronously rethrown, causing the test to fail (as you'd expect). For example:

itAsync("works", async () => {
    const res = await fetchSomeData();
    assert(res.valid);
});

What if you forget to use itAsync, and instead use it? Jest stalls. Could be worse - at least it's not a false report of no bugs!

I'm not sure that this approach (re-writing global.Promise via an import) will work right with mocked modules, because babel-jest does some magic to re-order imports.

If you're using React, you can call clock.runAll() inside act - but, beware! If a useEffect hook is pending, it will run after the function provided to act! You may need to use this pattern:

act(() => {
    doSomething();
    clock.runAll();
});
act(() => clock.runAll());

This gets worse if an effect schedules a task which triggers another effect, in which case you will need to have another act(() => clock.runAll()); afterwards. Hopefully that's rare in practice.

You'll also want to call clock.reset() before each testcase to prevent cross-test contamination (e.g., one test succumbs to an infinite loop and then all the rest fail similarly - true story). I don't have a good solution for where to put this - currently I have beforeEach(() => clock.reset()) in the same file that defines itAsync, but that's quite obviously a hack. I guess this will be handled neatly once lolex is properly integrated with Jest instead of bolted on in userland.

Finally, here's the relevant bits of jest.config.js:

    testEnvironment: "./test-util/fake-timer-environment.js",
    // These lines tell Jest to hit everything with Babel, including node_modules/
    transform: {
        "^.+$": "babel-jest",
    },
    transformIgnorePatterns: [],

Hopefully, all of that sounds reasonable and obvious enough - though I assure you that some of it took a lot of puzzling out!

Go forth and test promises without fear.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment