Testing

Testing setTimeout Functions in React Components

I had a component that showed a success message for 3 seconds, then hid it. Simple feature. The test? It passed locally, failed in CI, passed again when I...

29 Apr 2024

Testing setTimeout Functions in React Components

I had a component that showed a success message for 3 seconds, then hid it. Simple feature. The test? It passed locally, failed in CI, passed again when I re-ran it. Classic flaky test.

The problem was real time. My test was waiting for actual seconds to pass. That is never going to be reliable.

The fix: fake timers

Fake timers let you control time in your tests. Instead of waiting for setTimeout to actually fire, you tell the test runner to skip forward. Time becomes a variable you control, not something you wait for.

Here is the pattern:

  1. Enable fake timers before the test runs
  2. Render the component and trigger the behavior
  3. Fast-forward time by the exact amount you need
  4. Assert the expected state
  5. Restore real timers in cleanup

A practical example

Say I have a notification component. It shows a message, then hides it after 3 seconds.

Tsx
import React, { useState, useEffect } from 'react';

function Notification({ message }: { message: string }) {
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => setVisible(false), 3000);
    return () => clearTimeout(timer);
  }, []);

  if (!visible) return null;
  return <div role="alert">{message}</div>;
}

The test:

Tsx
import { render, screen, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

describe('Notification', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllTimers();
  });

  it('hides the message after 3 seconds', () => {
    render(<Notification message="Saved!" />);
    expect(screen.getByRole('alert')).toBeInTheDocument();

    act(() => {
      vi.advanceTimersByTime(3000);
    });

    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  it('stays visible before 3 seconds', () => {
    render(<Notification message="Saved!" />);

    act(() => {
      vi.advanceTimersByTime(2999);
    });

    expect(screen.getByRole('alert')).toBeInTheDocument();
  });
});

Why act() matters

Wrapping advanceTimersByTime in act() tells React to process all state updates triggered by the timer. Without it, you get warnings and potentially stale assertions. React needs to know the world changed.

The trade-off

Fake timers make tests deterministic and fast. But they add cognitive overhead. You are now responsible for advancing time manually. If your component has multiple timers or nested setTimeout calls, the test can get complex fast.

For simple delays, fake timers are the right call. For complex timing chains, consider whether the component design itself needs simplifying.

Common mistakes

  • Forgetting cleanup. Always restore real timers in afterEach. Leaked fake timers poison other tests.
  • Not wrapping in act(). React state updates from timers need act() to flush properly.
  • Using runAllTimers() carelessly. If you have a setInterval, runAllTimers creates an infinite loop. Use advanceTimersByTime with a specific value instead.

Keep reading