Skip to content

Instantly share code, notes, and snippets.

@quasicomputational
Last active March 25, 2019 09:45
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save quasicomputational/1651489425f7f3b3a918ab857021d68b to your computer and use it in GitHub Desktop.
Save quasicomputational/1651489425f7f3b3a918ab857021d68b 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 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.

Up-front configuration

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",
                ],
            },
        },
    };
};

Here's what you need in your jest.config.js to tell Jest to hit everything with Babel, including node_modules/:

    transform: {
        "^.+$": "babel-jest",
    },
    transformIgnorePatterns: [],

Next, faking things. lolex's implementation of fake timers 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.

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.

We don't put this in one of Jest's environments, because the test runner also runs in that environment and needs access to real setTimeout for detecting stalled tests.

So - call it test-util/fake-time.mjs:

import bluebird from "bluebird";
import lolex from "lolex"

// TODO: just use jest's lolex integration once that lands - https://github.com/facebook/jest/pull/7776 is the current PR.
global.clock = lolex.install({
    toFake: ["setTimeout", "clearTimeout", "setImmediate", "clearImmediate","setInterval", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame", "requestIdleCallback", "cancelIdleCallback", "hrtime", "nextTick", "queueMicrotask"],
    loopLimit: 50000,
    target: global,
});

// Make sure that one test run doesn't pollute others (e.g., a runaway infinite loop in one test doesn't cascade and cause the rest to fail).
beforeEach(() => clock.reset());

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

// Note order: this is set after installing lolex.
bluebird.setScheduler(queueMicrotask);

global.Promise = bluebird;

So, it has to be in a file that gets run before the tests. 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, or else global.Promise is the native promise object. More investigation is needed.

Writing tests

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

I also defined a couple of helper functions:

const runAsync = (fn) => {
    let stalled = true;
    Promise.resolve(fn()).then(() => {
        stalled = false;
    });
    clock.runAll();
    if (stalled) {
        throw new Error("async function stalled.");
    }
};

export const beforeEachAsync = (fn) => {
    beforeEach(() => runAsync(fn));
};

export const itAsync = (name, fn) => {
    it(name, () => runAsync(fn));
};

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), and stalled tests (e.g., waiting on a promise that never fulfils) are detected. For example:

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

What if you forget to use itAsync, and instead use it? The test will time out. Not quite ideal, but much better than stalling or, God forbid, appearing to pass while actually being broken.

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. More investigation is needed.

If you're doing property-based testing or something similar where you can have multiple 'tests' in a single test-case, be aware that you'll want a clock.reset() before each iteration and likely at some point during the body of the iteration, or else the iterations aren't properly isolated from each other and they can fail as a cascade. This is particularly bad when your fuzzer is trying to shrink an example and it gets down to nothing because the timers keep breaking!

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.

React

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 which schedules a task, in which case you will need to have another act(() => clock.runAll()); afterwards. Hopefully that's rare in practice.

Other approaches

Why go through the song and dance of using Bluebird? What's wrong with Jest's or Lolex's faked timers and native promises?

Both Jest and Lolex fake timers (i.e., macro-tasks) synchronously. But promises are based on micro-tasks, which must run before the next macro-task. Running macro-tasks synchronously while leaving micro-tasks asynchronous results in the wrong order of execution.

Why not use native promises and a single non-fake setImmediate call to flush the promise queue at the end of the test?

Because a further macro-task might be scheduled by the micro-tasks in the queue.

Okay, so keep scheduling with setImmediate until there are no more tasks scheduled by the code under test.

Sure, that'd work. But, to my knowledge, there's no ready-made solution for doing this - it's DIY.

Doing this right involves scheduling new macro-tasks an unbounded amount of times; it's not too tricky, but it's also not trivial. You'll need a decent implementation of a priority queue. You'll also need to bind Node's unhandled promise rejection event to stuff the error somewhere you can get at it, and then make sure it's appropriately propagated.

Further note that then the equivalent of clock.runAll() will be asynchronous and need to be awaited - this matters if you want to use it with act from React, because you can't await inside act today.

This might be a better solution long-term - less compilation is a good thing in my book. But it won't work for me today and I want my tests to be green now, not at some point in the future.

Comments, suggestions & improvements

This document is a work in progress, describing my current best understanding of how to fake time in tests. If you see something wrong or which could be improved, please drop me a mail (preferred) or leave a comment on this Gist (but bear in mind that I don't check this Gist for comments regularly and Gist comments don't generate notifications). Because this document's distilling the approach I'm using in my code, chances are good that I'll want to apply any new knowledge there, too!

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