Hola! Lazy dev here.
React testing is hard. Especially react testing outside the browser environment, like with Jest and JSdom. Let's try to reverse engineer react's act()
, understand why do we need it, and think about UI testing overall.
History
Today I meet this tweet by @floydophone
And was inspired to write about how your tests work inside your terminal when you are testing in node.js. Let's start from the question – why do we need this "magic" act()
function.
What is act()
Here is a quote from react.js docs:
To prepare a component for assertions, wrap the code rendering it and performing updates inside an act() call. This makes your test run closer to how React works in the browser.
So the problem that act()
is solving – It delays your tests until all of your updates were applied before proceeding to the next steps. When you are doing any kind of user interaction, like this
act(() => {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
React is not updating UI immediately, thanks to the Fiber architecture. It will update it asynchronously in some time after the click, so we need to wait for UI to be updated.
And here is a problem
The main problem here – act()
is actually a crutch and you will probably agree that it is not a perfect solution. Tests that you (probably) are writing are synchronous. It means that commands and assertions that tests are doing are executed one-by-one without any waiting.
UI works differently – UI is async by nature.
Reverse engineer it
Let's look more closely at the implementation of this function, right from the react sources. We only need 2 files ReactTestUtilsPublicAct and ReactFiberWorkLoop.
I will skip not interesting parts, but the code is not so big so you can read it yourself 🙃 Let's start from the main point of the act function:
let result;
try {
result = batchedUpdates(callback);
} catch (error) {
// on sync errors, we still want to 'cleanup' and decrement actingUpdatesScopeDepth
onDone();
throw error;
}
And this magic batchedUpdates
function has a pretty simple yet powerful implementation.
export function batchedUpdates<A, R>(fn: A => R, a: A): R {
const prevExecutionContext = executionContext;
executionContext |= BatchedContext;
try {
return fn(a);
} finally {
executionContext = prevExecutionContext;
if (executionContext === NoContext) {
// Flush the immediate callbacks that were scheduled during this batch
resetRenderTimer();
flushSyncCallbackQueue();
}
}
}
This particular function is called inside the react when during the render phase react exactly knows that all updates are done and we can render the dom. And it starts the reconciliation and synchronous dom updating after.
After batchedUpdates
our code has 2 branches depends on how you used it. If you passed a synchronous function inside the act
, like
act(() => {
ReactDOM.render(<Counter />, container);
});
It will call the function flushWork
which is nothing more than a sync while
loop
const flushWork =
Scheduler.unstable_flushAllWithoutAsserting ||
function() {
let didFlushWork = false;
while (flushPassiveEffects()) {
didFlushWork = true;
}
return didFlushWork;
};
It looks like for concurrent mode a new specific hook implemented to stop all the effects together (
unstable_flushAllWithoutAsserting
)
But for now, it is just a sync while loop that stops the synchronous execution until all the DOM updating work is done. Pretty clumsy solution, don't you think?
Async execution
More interesting is coming when you are passing an async function as a callback. Lets go to another code branch:
if (
result !== null &&
typeof result === 'object' &&
typeof result.then === 'function'
)
// ... not interesting
result.then(
() => {
if (
actingUpdatesScopeDepth > 1 ||
(isSchedulerMocked === true &&
previousIsSomeRendererActing === true)
) {
onDone();
resolve();
return;
}
// we're about to exit the act() scope,
// now's the time to flush tasks/effects
flushWorkAndMicroTasks((err: ?Error) => {
onDone();
if (err) {
reject(err);
} else {
resolve();
}
});
},
err => {
onDone();
reject(err);
},
);
Here we are waiting for our passed callback (the result
is returned by batchedUpdates
function) and if after we are going to more interesting function flushWorkAndMicroTasks
. Probably the most interesting function here :)
function flushWorkAndMicroTasks(onDone: (err: ?Error) => void) {
try {
flushWork();
enqueueTask(() => {
if (flushWork()) {
flushWorkAndMicroTasks(onDone);
} else {
onDone();
}
});
} catch (err) {
onDone(err);
}
}
It is doing the same as the sync version (that only calling flushWork()
). But it wraps the call enqueueTask
, which is a hack only to avoid setTimeout(fn, 0)
.
an enqueueTask function
export default function enqueueTask(task: () => void) {
if (enqueueTaskImpl === null) {
try {
// read require off the module object to get around the bundlers.
// we don't want them to detect a require and bundle a Node polyfill.
const requireString = ('require' + Math.random()).slice(0, 7);
const nodeRequire = module && module[requireString];
// assuming we're in node, let's try to get node's
// version of setImmediate, bypassing fake timers if any.
enqueueTaskImpl = nodeRequire.call(module, 'timers').setImmediate;
} catch (_err) {
// we're in a browser
// we can't use regular timers because they may still be faked
// so we try MessageChannel+postMessage instead
enqueueTaskImpl = function(callback: () => void) {
if (__DEV__) {
if (didWarnAboutMessageChannel === false) {
didWarnAboutMessageChannel = true;
if (typeof MessageChannel === 'undefined') {
console.error(
'This browser does not have a MessageChannel implementation, ' +
'so enqueuing tasks via await act(async () => ...) will fail. ' +
'Please file an issue at https://github.com/facebook/react/issues ' +
'if you encounter this warning.',
);
}
}
}
const channel = new MessageChannel();
channel.port1.onmessage = callback;
channel.port2.postMessage(undefined);
};
}
}
return enqueueTaskImpl(task);
}
The main goal of this function is only to execute a callback in the next tick of the event loop. That's probably why react is not the best in terms of bundle size :)
Why async?
It is a pretty new feature, probably needed more for concurrent mode, but it allows you to immediately run stuff like Promise.resolve
aka microtasks for example when mocking API and changing real promise using Promise.resolve
with fake data.
import * as ReactDOM from "react-dom";
import { act } from "react-dom/test-utils";
const AsyncApp = () => {
const [data, setData] = React.useState("idle value");
const simulatedFetch = async () => {
const fetchedValue = await Promise.resolve("fetched value");
setData(fetchedValue);
};
React.useEffect(() => {
simulatedFetch();
}, []);
return <h1>{data}</h1>;
};
let el = null;
beforeEach(() => {
// setup a DOM element as a render target
el = document.createElement("div");
// container *must* be attached to document so events work correctly.
document.body.appendChild(el);
});
it("should render with the correct text with sync act", async () => {
act(() => {
ReactDOM.render(<AsyncApp />, el);
});
expect(el.innerHTML).toContain("idle value");
});
it("should render with the correct text with async act", async () => {
await act(async () => {
ReactDOM.render(<AsyncApp />, el);
});
expect(el.innerHTML).toContain("fetched value");
});
Both tests passing 😌. Here is a live example (you can open sandbox and run tests inside using "Tests" tab):
It is fun that it works, but if you will change Promise.resolve
to literally anything like this:
const fetchedValue = await new Promise((res, rej) => {
setTimeout(() => res("fetched value"), 0);
});
// it doesn't work ¯\_(ツ)_/¯
Replace
It is pretty easy to replace any act()
call by using simple setTimeout(fn, 0)
:
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
await new Promise((res, rej) => {
setTimeout(res, 0);
});
will work in most cases :) some sources
But why
The main question – why do we need it? So much ~not good~ code that confuses everybody? The answer – our tests that are running inside node.js and trying to be "sync" while the UI as async.
And that's why you will never need any kind of act()
if you are rendering React components in the real browser and using async test-runner, like Cypress for component testing
Thank you
Thank you for reading, hope it is more clear why do we need act()
for most plain react unit testing.
And no act()
was not harmed in the making of this article :D
Top comments (2)
I think the
act
is weird too, your blog helped me a lot, thx~In
Replace
,the source code may like this: codesandbox.io/s/determined-pine-x...