I created a simple countdown component that I would like to test is working correctly with React testing library. Everything works as expected in a browser but when testing the rendered output never advances more than one second regardless of the values used.
Component (custom hook based on Dan Abramovs blog post):
import React, { useEffect, useRef, useState } from "react"
import TimerButton from "../TimerButton/TimerButton"
// custom hook
function useInterval(callback, delay, state) {
const savedCallback = useRef()
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback
}, [callback])
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current()
}
if (state.isOn) {
let id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [state])
}
const Timer = () => {
const initialState = {
minutes: 25,
seconds: 0,
isOn: false,
}
const [timerData, setTimerData] = useState(initialState)
useInterval(
() => {
if (timerData.seconds === 0) {
if (timerData.minutes === 0) {
setTimerData({ ...timerData, isOn: false })
} else {
setTimerData({
...timerData,
minutes: timerData.minutes - 1,
seconds: 59,
})
}
} else {
setTimerData({ ...timerData, seconds: timerData.seconds - 1 })
}
},
1000,
timerData,
)
const displayedTime = `${
timerData.minutes >= 10 ? timerData.minutes : `0${timerData.minutes}`
}:${timerData.seconds >= 10 ? timerData.seconds : `0${timerData.seconds}`}`
const startTimer = () => {
setTimerData({ ...timerData, isOn: true })
}
const stopTimer = () => {
setTimerData({ ...timerData, isOn: false })
}
const resetTimer = () => {
setTimerData(initialState)
}
return (
<div className="timer__container" data-testid="timerContainer">
<div className="timer__time-display" data-testid="timeDisplayContainer">
{displayedTime}
</div>
<div className="timer__button-wrap">
<TimerButton buttonAction={startTimer} buttonValue="Start" />
<TimerButton buttonAction={stopTimer} buttonValue="Stop" />
<TimerButton buttonAction={resetTimer} buttonValue="Reset" />
</div>
</div>
)
}
export default Timer
And the test (there is a beforeEach
and afterEach
calling jest.useFakeTimers()
and jest.useRealTimers()
respectively above):
it("Assert timer counts down by correct interval when starting timer", async () => {
render(<Timer />)
const initialTime = screen.getByText("25:00")
const startTimerButton = screen.getByText("Start")
expect(initialTime).toBeInTheDocument() // This works fine
fireEvent.click(startTimerButton)
act(() => {
jest.advanceTimersByTime(5000)
})
await waitFor(() => expect(screen.getByText("24:55")).toBeInTheDocument())
})
Output of the test:
Unable to find an element with the text: 24:55. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, style
<body>
<div>
<div
data-testid="timerContainer"
>
<div
data-testid="timeDisplayContainer"
>
24:59 <--- This value is always the same
</div>
<div
>
{ ... }
</div>
</div>
</body>
Id like to assert that the correct time is being displayed after moving the time forward by different increments, if I can do this then it will allow for a much more comprehensive set of tests.
I have tried using many of the timing methods in Jest including advanceTimersByTime(), runOnlyPendingTimers(), runAllTimers() to no avail, I can never get the time to advance by more than 1 second. I have also added console.log()
s in at various points in the useInterval
function and can verify that the function is being called a varying number of times depending on how much I try to advance the time by (it gets called numbers of seconds 1 times as expected).
When logging out the state data I have seen that the timerData
state does not update except for the last run through:
callback {"minutes":25,"seconds":0,"isOn":true} // Every time but the last
callback {"minutes":24,"seconds":59,"isOn":true} // The last time only
Im thinking it must be something to do with how Im referencing the state values during update, or how Jests fake timers interact with state updates though I really dont know at this point
CodePudding user response:
So, after a lot of playing and trying many different methods I have a solution for this.
Further investigation into the calling of the useInterval() function called showed that:
- the correct lines to update state were being reached, but the component was only re-rendering once after all these loops of the setInterval function despite the number of updates to state that were supposedly triggered (verified using console logs)
- altering the time passed to setInterval (
i
) to be less than 500ms showed that the time was advancing by amounts roughly equivalent to1 % i
- the component only re-renders once for each tick of the setInterval function that occurs under the time passed to
jest.advanceTimersByTime()
From this it appears that for each call of jest.advanceTimersByTime()
only one iteration of the setInterval function can be moved through, and so the only way I found to move the interval forward by the correct amount of time is to call advanceTimersByTime() repeatedly via a helper function:
const advanceJestTimersByTime = (increment, iterations) => {
for (let i = 0; i < iterations; i ) {
act(() => {
jest.advanceTimersByTime(increment)
})
}
}
Test updated to:
it("Assert timer counts down by correct interval when starting timer", async () => {
render(<Timer />)
const initialTime = screen.getByText("25:00")
const startTimerButton = screen.getByText("Start")
expect(initialTime).toBeInTheDocument()
fireEvent.click(startTimerButton)
advanceJestTimersByTime(1000, 60)
await waitFor(() => expect(screen.getByText("24:00")).toBeInTheDocument())
})
This passes and allows for assertions to be made at any point for the countdown