如何用Jest/React Testing Library测试setInterval的DOM变更?
问题
我做了一个简易倒计时React组件,浏览器里运行正常,但用React Testing Library测试时,不管设置多少时间增量,渲染输出只前进1秒。
组件代码(基于Dan Abramov博客的自定义Hook)
import React, { useEffect, useRef, useState } from "react" import TimerButton from "../TimerButton/TimerButton" // custom hook function useInterval(callback, delay, state) { const savedCallback = useRef() // 保存最新的回调函数 useEffect(() => { savedCallback.current = callback }, [callback]) // 初始化定时器 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
测试代码
(前后有beforeEach和afterEach分别调用jest.useFakeTimers()和jest.useRealTimers())
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) act(() => { jest.advanceTimersByTime(5000) }) await waitFor(() => expect(screen.getByText("24:55")).toBeInTheDocument()) })
测试输出
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 class="timer__container" data-testid="timerContainer" > <div class="timer__time-display" data-testid="timeDisplayContainer" > 24:59 <--- 这个值始终不变 </div> <div class="timer__button-wrap" > { ... } </div> </div> </body>
我试过Jest的advanceTimersByTime()、runOnlyPendingTimers()、runAllTimers()等方法都没用,DOM只走1秒。在useInterval里加console.log()确认函数调用次数是对的,但timerData状态只在最后一次调用时更新,怀疑是状态引用或者Jest假定时器和状态更新的交互问题。
解决方案
问题根源
- 闭包导致的状态 stale 问题:传给
useInterval的回调函数捕获了初始的timerData引用,每次定时器触发时,都是基于这个初始状态(25:00)来更新,多次触发后,最后一次更新会覆盖之前的操作,最终只显示减1秒的结果。 - useInterval Hook 的依赖冗余:Hook 依赖整个
state对象重建定时器,但回调里的状态并没有同步更新,导致每次触发都用旧状态计算。
修复步骤
1. 改用函数式更新状态
修改setTimerData的调用方式,使用函数式更新,确保每次更新都基于最新的状态快照,避开闭包陷阱:
useInterval( () => { setTimerData(prevState => { if (prevState.seconds === 0) { if (prevState.minutes === 0) { return { ...prevState, isOn: false } } else { return { ...prevState, minutes: prevState.minutes - 1, seconds: 59, } } } else { return { ...prevState, seconds: prevState.seconds - 1 } } }) }, 1000, timerData, )
2. 优化useInterval的依赖数组
只依赖必要的state.isOn和delay,避免不必要的定时器销毁重建,同时确保回调始终是最新的:
function useInterval(callback, delay, state) { const savedCallback = useRef() useEffect(() => { savedCallback.current = callback }, [callback]) useEffect(() => { function tick() { savedCallback.current() } if (state.isOn) { const id = setInterval(tick, delay) return () => clearInterval(id) } }, [state.isOn, delay]) // 仅依赖定时器开关状态和间隔时间 }
3. 调整测试代码
React Testing Library的waitFor已经封装了act()逻辑,无需手动包裹定时器操作:
it("Assert timer counts down by correct interval when starting timer", async () => { render(<Timer />) expect(screen.getByText("25:00")).toBeInTheDocument() fireEvent.click(screen.getByText("Start")) jest.advanceTimersByTime(5000) await waitFor(() => { expect(screen.getByText("24:55")).toBeInTheDocument() }) })
修复逻辑说明
- 函数式更新让
setTimerData每次都获取最新的状态,多次定时器触发的更新会依次生效,不会被覆盖。 - 优化依赖数组减少了定时器的重复创建,同时保证回调始终指向最新版本。
- 去掉手动
act()调用,避免干扰RTL的异步状态更新处理流程。
内容的提问来源于stack exchange,提问作者styabzin
相关产品推荐
相关产品推荐

