Repository navigation
Flaky tests: Asynchronous queries can return unmounted elements #865
Description
Edit: Skip comments about debugging and jump to the root cause.
- If your issue is regarding one of the query APIs (
getByText,
getByLabelText, etc), then please file it on thedom-testing-library
repository instead. If you file it here it will be closed. Thanks :)
This is related to asynchronous queries (findBy*) but the issue is not on dom-testing-library. See testing-library/dom-testing-library#876.
@testing-library/reactversion:"^11.1.0"- Testing Framework and version:
- fresh project generated with CRA 4.0.1
jest@26.6.0
- DOM Environment:
jest-environment-jsdom@26.6.2jsdom@16.4.0
Relevant code or config:
Also available in codesandbox below.
This issue comes up randomly so below we are generating 200 tests:
import React, { useEffect, useState } from "react";
import { render, waitForElementToBeRemoved, screen } from "@testing-library/react";
function Component() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer1 = setTimeout(() => setVisible(true), 10);
const timer2 = setTimeout(() => setVisible(false), 15);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, []);
return (
<div>
{visible && <span id="test-id">Content</span>}
</div>
);
}
for(const i of Array(200).fill(null).map((_, i) => i)) {
test(`unstable test ${i}`, async () => {
render(<Component />);
const content = await screen.findByText("Content");
await waitForElementToBeRemoved(content);
});
}Above is only a minimal repro. My real use case includes querying "Loading" text which is visible while MSW is handling the request. It does not use setTimeout at all. Something like:
// Click submit button
userEvent.click(screen.getByRole("button", { name: "Submit" }));
// Loader should appear
const loader = await screen.findByText("Loading");
// Loader should disappear once request resolves
await waitForElementToBeRemoved(loader);What you did:
Queried element with asynchronous query findBy*. I would expect element returned to be present in the DOM. If it wasn't the query should throw error.
Passed the queried element to waitForElementToBeRemoved.
What happened:
Element returned from findByText was not in DOM when waitForElementToBeRemoved started handling it.
FAIL src/App.test.js
× unstable test (66 ms)
● unstable test
The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.
33 | render(<Component />);
34 | const content = await screen.findByText("Content");
> 35 | await waitForElementToBeRemoved(content);
| ^
36 | });
37 |
38 |
at initialCheck (node_modules/@testing-library/dom/dist/wait-for-element-to-be-removed.js:16:11)
at waitForElementToBeRemoved (node_modules/@testing-library/dom/dist/wait-for-element-to-be-removed.js:39:3)
at Object.<anonymous> (src/App.test.js:35:11)
Most of the time these issues come up really randomly. We might have successful builds for months on CI and then this error occurs. Triggering new build usually fixes this. 😄
Reproduction:
The codesandbox is still showing incorrect error message Cannot read property 'parentElement' of null but this was fixed in testing-library/dom-testing-library#871.
-
React: https://codesandbox.io/s/dom-testing-libraryissues876-2-fe3fi?file=/src/React.test.js
-
DOM: https://codesandbox.io/s/dom-testing-libraryissues876-2-fe3fi?file=/src/Dom.test.js
Problem description:
Asynchronous queries can return elements which have unmounted the DOM.
I was hoping the initialCheck done by waitForElementToBeRemoved would have been the cause but it looks like the parentElement is not even present before element is given to it.
const content = await screen.findByText("Content");
console.log(content.outerHTML); // <span id="test-id">Content</span>
console.log(content.parentElement); // null
await waitForElementToBeRemoved(content); // The element(s) given to waitForElementToBeRemoved are already removed.By setting the isAsyncActSupported to false from dist/act-compat.js this issue disappears. However errors fill stdout but tests are passing. I have no idea what I'm doing here but this works 😃 .
react-testing-library/src/act-compat.js
Line 20 in deafd51
| let isAsyncActSupported = null |
I think act-compact.js is causing asynchronous queries to run additional cycles/ticks/micro-queues instead of returning the element instantly. This causes setTimeouts and/or Promises to run before the element is returned. In practice the queried element can unmount before asynchronous query is resolved.
Suggested solution:
I'm not familiar with the act wrapper of RTL but I think the fix should be applied there.
There is a workaround for this: Don't use waitForElementToBeRemoved.
const content = await screen.findByText("Content");
await waitFor(() => expect(content).not.toBeInTheDocument());
Activity
MatanBobi commentedon Jan 18, 2021
I hope I'm getting this correctly but IMO the problem here is these two lines:
waitForElementToBeRemoveduseswaitForwhich calls the callback function it accepts every tick in order to see if the return value of your callback is present.IMO what happens here is that it only reads that element once (when you initiate your variable) and not at the next ticks.
Did you try something like this:
I do recall something about passing an element instead of a callback to
waitForElementToBeRemovedthough I can't find the related issue.AriPerkkio commentedon Jan 18, 2021
This is not really an issue with
waitForElementToBeRemovedsince element is already unmounted when returned from the query:Test with the suggested approach:
This can also be reproduced without React. Here's example of DTL. Just change the import of
screen. This really points out the problem is withreact-testing-librarys way of handling the async queries.https://codesandbox.io/s/dom-testing-libraryissues876-2-fe3fi?file=/src/Dom.test.js:100-308
AriPerkkio commentedon Jan 22, 2021
If
waitForElementToBeRemovedis confusing the test setup here too much it can be replaced with following:alexkrolick commentedon Jan 22, 2021
Does this still happen if you make the timeout longer than the React batch time (16ms)?
AriPerkkio commentedon Jan 22, 2021
With this setup:
So async queries are unstable if content updates are done fast enough. As said earlier my real use case utilizes MSW behind the queries. If I'm looking at this correctly it looks like MSW is using 5ms delay by default. https://github.com/mswjs/msw/blob/9b667c74f635735e1b941baf61db9f1b77c5a5e3/src/context/delay.ts#L7-L12
But this is just my use case. I think RTL should work with any delay values.
AriPerkkio commentedon Feb 21, 2021
I had some time to debug this further. Looks like this is caused by nested awaits in
asyncWrapper.react-testing-library/src/pure.js
Lines 12 to 18 in a241cb8
More debugging info, narrows the issue to asyncWrapper
Replace
asyncActwithreact-dom/testing-utilsact just to narrowact-compactout.acthas resolved.A quick fix is to resolve the outer scope immediately when callback resolves:
Now I can run 2000 tests with 1ms timeout difference and they all pass. There is however a new error thrown by
act:Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);.Clearly this fix isn't perfect. I'll have to study the
react-dom/test-utilsnext.setup for testing-library/react-testing-library#865
AriPerkkio commentedon Mar 7, 2021
React's act will flush micro tasks once given callback resolves. In practice this means that once callback created by
findByqueries resolves the state of DOM may change beforeactresolves. There is no way to resolveactimmeadiately when element queried byfindByappears. Asynchronous findBy queries don't provide stable state of DOM. It simply won't work as intended.Repro setup in react repository with minimal
waitFor:I see two possible solutions for this issue:
1. Skip
flushWorkAndMicroTasksofactcallsAs done in this POC commit an option for bailing out of
actis required. Resolve theactimmediately as queried element is found. With this setup the tests pass even if element disappears 1ms after its mount.2. Document flakiness of
findByqueries in testing library docsDocumentation should clearly indicate that asynchronous queries may resolve in different state of DOM. Elements returned from these queries may already have unmounted. Even if your tests work today they may randomly fail in future.
[-]Asynchronous queries can return unmounted elements[/-][+]Flaky tests: Asynchronous queries can return unmounted elements[/+]alexkrolick commentedon Mar 9, 2021
Trying to recap:
The way
await findBy...is supposed to work is 1) wait for effects to clear so that DOM is stable in terms of "known pending work" 2) query. In theory if the reason for a change is also wrapped in act() (e.g. a fireEvent call), 1) should generally be true. It sounds like a problem comes from when the effect is triggered without an initial act(), e.g. through a timer in the app code. In this case, the act() isn't wrapping an action, it is wrapping the query itself, which was triggered due to a test retry timer or MutationObserver event - and effects that happened in that sequence are flushed after the result.alexkrolick commentedon Mar 18, 2021
And... I've seen this one in the wild now.
Basically this:
The expectation throws an error but not the findBy.
Since it's a transient state, removing the assertion can unblock the test, but still.
vadeneev commentedon May 20, 2021
Faced same kind of issue:
happens mostly on CI during high CPU load ( in general suite takes 2sec to complete, with CPU load it might be 10x longer and even more ). Tried to reproduce it at laptop with CPU throttling ( let's zip smth ;) ) and randomly hit it.
Firstly I thought that it happened because of MSV used, but issues came in not chained with requests places.
AhmedBHameed commentedon Jun 11, 2021
So it is happening like this:
If you have a flag with value
false(only when initiated with false) let us say it is loading flag, the async call will change the flag totruethenfalse. I thinkwaitForElementToBeRemovedapi has no way to detect that there is async call is going on in the code so it just terminate the test byelement not found already in domortimeout.In my case, async call is
axiosand i just mocked it usingmswlibrary. In the test, i added a simple line:At that point of delay, I'm sure that async call is finished and component is stable with no
state changesanymore.That solved my issue so far, but I lost the check for the loading indicator because I don't know how to predect the loading element.
As a question, Is there aways to detect that react component has async call is going on and? Maybe a wrapper? Does
<Suspense>works here?AriPerkkio commentedon Jul 1, 2021
@vadeneev and @josh-biddick, it sounds like you have run exactly into this same issue. I'm not sure about @AhmedBHameed's case.
I've found one tricky way to identify such unstable tests in local environment. By running multiple
npm run testscripts parallel, the CPU load is high enough to make these bugs appear. This way we've been able to fix these issues in local before pushing code to CI.I wonder if this will be fixed by #937 in future since it's removing the
asyncWrapperwhich was identified to be one of the root cause here.alessandrocapra commentedon Dec 21, 2021
Hello @AriPerkkio , thanks for this post, I thought I was going nuts. I've followed your advice on the parallel local tests to see which ones were failing, but how did you actually solved them? I can't seem to find a way to make them run correctly every single time, some of them keep failing
I am using the same setup as you, with MSW for mocking the API. When I try to test a failing API call (e.g. 400), for some reason with the parallel tests the expectations keep on failing. As an example:
AriPerkkio commentedon Dec 21, 2021
@alessandrocapra without seeing the whole test case + component, or a minimal repro of your specific problem it is difficult to give any accurate instructions. Based on your code snippet I'm not 100% sure this issue is its root cause.
But in general: If you query elements which can change their state/attributes after some time has passed, do not make any assumptions of their state. For example, if a "Loading" text is visible only when fetch request is active, do not assume the queried element is in the DOM after
findBy*query returned it.You can see this in practice at https://codesandbox.io/s/dom-testing-libraryissues876-2-fe3fi?file=/src/React.test.js.
With 1000 tests I'm seeing following results with average laptop:
Test Summary | 483 failed | 517 passed | 1000 total. About 48% of the timefindByTextreturned an element without.parentElement.By changing the
waitForElementToBeRemovedI always getTest Summary | 1000 passed | 1000 total.render(<Component />); const content = await screen.findByText("Content"); - await waitForElementToBeRemoved(content); + await waitFor(() => { + expect(content).not.toBeInTheDocument(); // Does not care if findByText returns element without parentElement (= unmounted DOM node) + });With this parallel running I've found these flaky tests from many projects. We were simply making assertions on elements returned by asynchronous queries. By refactoring the test case a bit like shown above we were able to fix these issues. In all cases in the end we were able to run projects containing hundreds of tests parallel with 100% success.
denniseffing commentedon May 26, 2023
Is this ever going to be fixed? We just ran into the same issue.
Anthony-YY commentedon Jan 23, 2024
encounter such flaky issues with the following dependencies:
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "8.0.1",
issue is not easily reproducible on the local machine but prone to fail on CI/CD.
since most of us run jest on CI/CD with the --silent option.
I then reproduce it locally much more frequently with the --silent option (seems the output matters? 🤔️).
the error message is "element could not be found in the document" which means the element is indeed found/exist but not located in the current dom tree when expecting it to be in the document.
In my case I don't have to use findBy* actually, after replace findBy with getBy my issues seems fixed.
sidrak19 commentedon Jan 28, 2024
Still encountering this issue in CI/CD