You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用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假定时器和状态更新的交互问题。


解决方案

问题根源

  1. 闭包导致的状态 stale 问题:传给useInterval的回调函数捕获了初始的timerData引用,每次定时器触发时,都是基于这个初始状态(25:00)来更新,多次触发后,最后一次更新会覆盖之前的操作,最终只显示减1秒的结果。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.04 19:35:18