Jest: setTimeout is being called too many times - reactjs

I am testing a react component that uses setTimeout. The problem is that Jest is saying that setTimeout is called even though it clearly isn't. There is a setTimeout to remove something from the ui and another one to pause the timer when the mouse is hovering over the component.
I tried adding a console.log() where the setTimeout is and the console log is never called, which means the setTimeout in the app isn't being called.
//app
const App = (props) => {
const [show, setShow] = useState(true);
const date = useRef(Date.now());
const remaining = useRef(props.duration);
let timeout;
useEffect(() => {
console.log('Should not run');
if (props.duration) {
timeout = setTimeout(() => {
setShow(false)
}, props.duration);
}
}, [props.duration]);
const pause = () => {
remaining.current -= Date.now() - date.current;
clearTimeout(timeout);
}
const play = () => {
date.current = Date.now();
clearTimeout(timeout);
console.log('should not run');
timeout = setTimeout(() => {
setIn(false);
}, remaining.current);
}
return (
<div onMouseOver={pause} onMouseLeave={play}>
{ show &&
props.content
}
</div>
)
}
//test
it('Should not setTimeout when duration is false', () => {
render(<Toast content="" duration={false} />);
//setTimeout is called once but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(0);
});
it('Should pause the timer when pauseOnHover is true', () => {
const { container } = render(<Toast content="" pauseOnHover={true} />);
fireEvent.mouseOver(container.firstChild);
expect(clearTimeout).toHaveBeenCalledTimes(1);
fireEvent.mouseLeave(container.firstChild);
//setTimeout is called 3 times but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(1);
});
So in the first test, setTimeout shouldn't be called but I receive that its called once. In the second test, setTimeout should be called once but is called 3 times. The app works fine I just don't understand what is going on with jest suggesting that setTimeout is being called more than it is.

I'm experiencing the exact same issue with the first of my Jest test always calling setTimeout once (without my component triggering it). By logging the arguments of this "unknown" setTimeout call, I found out it is invoked with a _flushCallback function and a delay of 0.
Looking into the repository of react-test-renderer shows a _flushCallback function is defined here. The Scheduler where _flushCallback is part of clearly states that it uses setTimeout when it runs in a non-DOM environment (which is the case when doing Jest tests).
I don't know how to properly proceed on researching this, for now, it seems like tests for the amount of times setTimeout is called are unreliable.

Thanks to #thabemmz for researching the cause of this, I have a hacked-together solution:
function countSetTimeoutCalls() {
return setTimeout.mock.calls.filter(([fn, t]) => (
t !== 0 ||
!String(fn).includes('_flushCallback')
));
}
Usage:
// expect(setTimeout).toHaveBeenCalledTimes(2);
// becomes:
expect(countSetTimeoutCalls()).toHaveLength(2);
It should be pretty clear what the code is doing; it filters out all calls which look like they are from that react-test-renderer line (i.e. the function contains _flushCallback and the timeout is 0.
It's brittle to changes in react-test-renderer's behaviour (or even function naming), but does the trick for now at least.

Related

Vitest issue with vi.spyOn() when used with clearInterval

I've created a Countdown timer component and am writing out tests. I want to test that the setInterval gets cleared when the distance is less than 0.
I've set up a test as follows using vi.spyOn(global, 'clearInterval'):
test('should clear the interval when the distance is less than 0', () => {
const date = setDate(SECOND);
vi.spyOn(global, 'clearInterval');
render(<CountdownClock endDate={date} etc.../>)
act(() => {
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
vi.clearAllMocks();
});
// expect assertions ...
});
The code itself handles the timer inside of a useEffect:
useEffect(() => {
// Start straight away (ie before first second elapses in setInterval)
if (distance() > 0) {
runTimer();
}
const interval = setInterval(() => {
if (distance() < 0) {
// Stop Timer
endTimer();
clearInterval(interval);
} else {
runTimer();
}
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
The above test sets the date to 1 second in the future and then I run setInterval twice more using vi.runOnlyPendingTimers(). This pushes the distance into negative which stops the timer and clears the interval.
I've logged this out and I can see that the spy works in this part of the code - I can see callCount of 1 after clearInterval has run.
The issue I get is that it then runs the useEffect return statement to clear up. At this point I get the error:
ReferenceError: clearInterval is not defined
❯ src/components/CountdownClock/CountdownClockContainer.tsx:94:12
92|
93| return () => {
94| clearInterval(interval);
| ^
95| };
I can't work out why clearInterval is undefined at this stage. My understanding is that spyOn doesn't create a mock and is rather 'spying' on the method so I can't figure out why clearInterval becomes undefined.
If anyone has any suggestions as to what might be the issue that would be great.

Test functionality with timeouts in react-testing-library and jest

I'm trying to follow TDD, and I have a span that should appear on screen after 5 seconds. I haven't implemented the span at all, so the test should fail, but currently it passes the test expect(messageSpan).toBeInTheDocument.
Here are my two tests:
it("doesn't show cta message at first", () => {
render(<FAB />);
const messageSpan = screen.queryByText(
"Considering a career in nursing? Join our team!"
);
expect(messageSpan).toBeNull(); // passes, as it should
});
it("should show the cta message after 5 secs", () => {
render(<FAB />);
setTimeout(() => {
const messageSpan = screen.getByText( // also tried queryByText instead of get
"Considering a career in nursing? Join our team!"
);
expect(messageSpan).toBeInTheDocument(); // also passes, even though messageSpan should throw an error.
}, 5000);
});
Here's my FAB component, where you can see there's no message at all:
export default function FAB() {
return (
// using styled-components; there's no content in any of these.
<StyledFABContainer>
<StyledFABButton>
<BriefcaseIcon />
</StyledFABButton>
</StyledFABContainer>
);
}
To complicate things, I don't plan to have a set function I call for the setTimeout. I will simply set state after a set time of 5 secs. So don't think I can use the suggestions in the timer mocks section of jest docs: https://jestjs.io/docs/timer-mocks
My two questions are:
a) Why would this pass and not throw an error/null?
b) How can I properly test setTimeout functionality in RTL?
UPDATE: Have tried using various combinations of useFakeTimers, act, waitFor etc., but no luck. Here's my current test as it's written out, and throwing two errors - one saying I need to use act when changing state (which I am, but still) and one saying my messageSpan is null:
it("cta message to display after 5 secs", async () => {
jest.useFakeTimers();
const el = document.createElement("div");
act(() => {
ReactDOM.render(<FAB />, el);
});
jest.advanceTimersByTime(5000);
const messageSpan = screen.queryByText(
"Considering a career in nursing? Join our team!"
);
expect(messageSpan).toBeInTheDocument();
});
You can not use setTimeout like this in your tests. An obvious reason would be that you do not want to wait 5 seconds in your test to then continue. Imagine a component that would change after 10min. You cant wait that long but should use jests mocked timers API instead.
You can progress the nodejs timer so your component changes, then immediately make your assertion.
Other remarks about the tests you wrote:
If you are awaiting changes you should use await waitFor(() => ...) from testing library. It checks the expectations to pass every 50ms by default for a total of 5sec. If all expectations pass it continues. You should make asynchronous assertions there.
"expect(messageSpan).toBeNull()" should be "expect(messageSpan).not.toBeInTheDocument()". Its good to go with the accessibility way testing library provides.
If working with an asynchronous test I found a solution on this blog: https://onestepcode.com/testing-library-user-event-with-fake-timers/
The trick is to set the delay option on the userEvent to null.
const user = userEvent.setup({ delay: null });
Here is a full test case
test("Pressing the button hides the text (fake timers)", async () => {
const user = userEvent.setup({ delay: null });
jest.useFakeTimers();
render(<Demo />);
const button = screen.getByRole("button");
await user.click(button);
act(() => {
jest.runAllTimers();
});
const text = screen.queryByText("Hello World!");
expect(text).not.toBeInTheDocument();
jest.useRealTimers();
});

React testing library - fakeTimers with waitFor/waitForElementToBeRemoved

Last time I have updated testing-library/dom from version 7.29.4 to 8.0.0. After that tests which have jest.useFakeTimers stopped working whenever waitFor/waitForElementToBeRemoved is used.
export default function Test() {
const [loaded, setLoaded] = useState(false);
const getDataCallback = useCallback(() => {
return getData();
}, [])
useEffect(() => {
getDataCallback().then(data => {
setLoaded(true)
});
}, [])
return (
<>
{
loaded ?
<>
{new Date().toDateString()} //displays current date
</>
: <Loader/>
}
</>
)}
Test code:
const mockFunc = jest.spyOn(api, "getData");
const fakeData = [{ date: "2020-01"}, { date: "2020-02"},];
beforeEach(() => {
jest.useFakeTimers("modern").setSystemTime(new Date(2020, 2, 3));
mockFunc.mockResolvedValue(fakeData);
})
it("test", async () => {
render(<Test />);
await waitForElementToBeRemoved(screen.queryByTestId("loader"));
expect(screen.getByText(/tue mar 03 2020/i)).toBeInTheDocument();
})
In this code it's some fake api call, when it's done then we want to display the current date. If the call is not finished, then some loader/spinner is on the screen. When I remove loader state and waitForElementToBeRemoved() from code I have mocked date on the screen and everything works like expected, otherwise real date is displayed.
I'm not sure what is happening inside of your getData, but if it is using setTimeout or similar, then you need to tell jest to advance the fake timers or flush them.
I had a similar issue where I was using real timers and all tests passed, then when using fake timers they all failed. In my scenario I think it was because my tests were not waiting for the timeout to finish and just immediately executed assertions as if timeouts had passed when they really hadn't. Adding jest.advanceTimersByTime(theSetTimeoutTime) before the calls to waitForElementToBeRemoved fixed my tests in almost all cases.

Function is executing even after leaving the page React js

When I navigate from home i.e, "/" to "/realtime" useEffect hook start the video from webcam, then I added a function handleVideoPlay for video onPlay event as shown below.
<video
className="video"
ref={videoRef}
autoPlay
muted
onPlay={handleVideoPlay}
/>
For every interval of 100ms, the code inside setInterval( which is inside the handleVideoPlay function) will run, which detects facial emotions using faceapi and draw canvas.
Here is my handleVideoPlay function
const [ isOnPage, setIsOnPage] = useState(true);
const handleVideoPlay = () => {
setInterval(async () => {
if(isOnPage){
canvasRef.current.innerHTML = faceapi.createCanvasFromMedia(videoRef.current);
const displaySize = {
width: videoWidth,
height: videoHeight,
};
faceapi.matchDimensions(canvasRef.current, displaySize);
const detections = await faceapi.detectAllFaces(videoRef.current, new
faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceExpressions();
const resizeDetections = faceapi.resizeResults(detections, displaySize);
canvasRef.current.getContext("2d").clearRect(0, 0, videoWidth, videoHeight);
faceapi.draw.drawDetections(canvasRef.current, resizeDetections);
faceapi.draw.drawFaceLandmarks(canvasRef.current, resizeDetections);
faceapi.draw.drawFaceExpressions(canvasRef.current, resizeDetections);
}else{
return;
}
}, 100);
};
The problem is when I go back the handleVideoFunction is still running, so for canvasRef it is getting null value, and it's throwing this error as shown below
Unhandled Rejection (TypeError): Cannot read property 'getContext' of null
I want to stop the setInterval block on leaving the page. I tried by putting a state isOnPage to true and
I set it to false in useEffect cleanup so that if isOnPage is true the code in setInterval runs else it returns. but that doesn't worked. The other code in useEffect cleanup function is running but the state is not changing.
Please help me with this, and I'm sorry if haven't asked the question correctly and I'll give you if you need more information about this to resolve.
Thank you
You need to clear your setInterval from running when the component is unmounted.
You can do this using the useEffect hook:
useEffect(() => {
const interval = setInterval(() => {
console.log('I will log every second until I am cleared');
}, 1000);
return () => clearInterval(interval);
}, []);
Passing an empty array to useEffect ensures the effect will only trigger once when it is mounted.
The return of the effect is called when the component is unmounted.
If you clear the interval here, you will no longer have the interval running once the component is unmounted. Not only that, but you are ensuring that you are not leaking memory (i.e. by indefinitely increasing the number of setIntervals that are running in the background).

When testing, code that causes React state updates should be wrapped into act

I have this test:
import {
render,
cleanup,
waitForElement
} from '#testing-library/react'
const TestApp = () => {
const { loading, data, error } = useFetch<Person>('https://example.com', { onMount: true });
return (
<>
{loading && <div data-testid="loading">loading...</div>}
{error && <div data-testid="error">{error.message}</div>}
{data &&
<div>
<div data-testid="person-name">{data.name}</div>
<div data-testid="person-age">{data.age}</div>
</div>
}
</>
);
};
describe("useFetch", () => {
const renderComponent = () => render(<TestApp/>);
it('should be initially loading', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('loading')).toBeDefined();
})
});
The test passes but I get the following warning:
Warning: An update to TestApp inside a test was not wrapped in
act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser
in TestApp
console.error
node_modules/react-dom/cjs/react-dom.development.js:506
Warning: An update to TestApp inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser
in TestApp
The key is to await act and then use async arrow function.
await act( async () => render(<TestApp/>));
Source:
https://stackoverflow.com/a/59839513/3850405
Try asserting inside 'await waitFor()' - for this your it() function should be async
it('should be initially loading', async () => {
const { getByTestId } = renderComponent();
await waitFor(() => {
expect(getByTestId('loading')).toBeDefined();
});
});
Keep calm and happy coding
I was getting the same issue which gets resolved by using async queries (findBy*) instead of getBy* or queryBy*.
expect(await screen.findByText(/textonscreen/i)).toBeInTheDocument();
Async query returns a Promise instead of element, which resolves when an element is found which matches the given query. The promise is rejected if no element is found or if more than one element is found after a default timeout of 1000ms. If you need to find more than one element, use findAllBy.
https://testing-library.com/docs/dom-testing-library/api-async/
But as you know it wont work properly if something is not on screen. So for queryBy* one might need to update test case accordingly
[Note: Here there is no user event just simple render so findBy will work otherwise we need to put user Event in act ]
Try using await inside act
import { act } from 'react-dom/test-utils';
await act(async () => {
wrapper = mount(Commponent);
wrapper.find('button').simulate('click');
});
test('handles server ok', async () => {
render(
<MemoryRouter>
<Login />
</MemoryRouter>
)
await waitFor(() => fireEvent.click(screen.getByRole('register')))
let domInfo
await waitFor(() => (domInfo = screen.getByRole('infoOk')))
// expect(domInfo).toHaveTextContent('登陆成功')
})
I solved the problem in this way,you can try it.
I don't see the stack of the act error, but I guess, it is triggered by the end of the loading when this causes to change the TestApp state to change and rerender after the test finished. So waiting for the loading to disappear at the end of the test should solve this issue.
describe("useFetch", () => {
const renderComponent = () => render(<TestApp/>);
it('should be initially loading', async () => {
const { getByTestId } = renderComponent();
expect(getByTestId('loading')).toBeDefined();
await waitForElementToBeRemoved(() => queryByTestId('loading'));
});
});
React app with react testing library:
I tried a lot of things, what worked for me was to wait for something after the fireevent so that nothing happens after the test is finished.
In my case it was a calendar that opened when the input field got focus. I fireed the focus event and checked that the resulting focus event occured and finished the test. I think maybe that the calendar opened after my test was finished but before the system was done, and that triggered the warning. Waiting for the calendar to show before finishing did the trick.
fireEvent.focus(inputElement);
await waitFor(async () => {
expect(await screen.findByText('December 2022')).not.toBeNull();
});
expect(onFocusJestFunction).toHaveBeenCalledTimes(1);
// End
Hopes this helps someone, I just spent half a day on this.
This is just a warning in react-testing-library (RTL). you do not have to use act in RTL because it is already using it behind the scenes. If you are not using RTL, you have to use act
import {act} from "react-dom/test-utils"
test('',{
act(()=>{
render(<TestApp/>)
})
})
You will see that warning when your component does data fetching. Because data fetching is async, when you render the component inside act(), behing the scene all the data fetching and state update will be completed first and then act() will finish. So you will be rendering the component, with the latest state update
Easiest way to get rid of this warning in RTL, you should run async query functions findBy*
test("test", async () => {
render(
<MemoryRouter>
<TestApp />
</MemoryRouter>
);
await screen.findByRole("button");
});

Resources