๐Ÿ™ˆ Examples

This page illustrates some examples that would fail the test with jest-doctor and how to fix them.

โฑ๏ธ Timers

Timers frequently cause flaky or hanging tests in large codebases.

In this example, a setInterval remains active after the test completes, preventing the process from exiting naturally. Jest will force termination, but the root cause remains hidden.

const doSomething = jest.fn();

it('will run but never stop', async () => {
  setInterval(() => {
    doSomething();
  }, 100);
  await waitFor(() => expect(doSomething).toHaveBeenCalled());
});

To fix the issue:

const doSomething = jest.fn();

it('will run but never stop', async () => {
  jest.useFakeTimers();
  setInterval(() => {
    doSomething();
  }, 100);
  jest.runOnlyPendingTimers();
  expect(doSomething).toHaveBeenCalled();
  jest.clearAllTimers();
});

It is important to call jest.clearAllTimers because otherwise the next test could also execute the setInterval by advancing timers. This can be also done in a afterEach block if there are more tests.

๐Ÿƒ Fake Timers

Fake timers are useful but can leak if timers from one test affect another.

const debounce = (func, wait) => {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
};

const mock = jest.fn();

jest.useFakeTimers();

afterEach(() => {
  jest.clearAllMocks();
});

it('should not fire immediatly', () => {
  const debouncer = debounce(mock, 100);
  debouncer();
  expect(mock).not.toHaveBeenCalled();
});

it('should fire once timeout complete', () => {
  const debouncer = debounce(mock, 100);
  debouncer();
  jest.runOnlyPendingTimers();
  expect(mock).toHaveBeenCalledTimes(1);
});

The second test will fail because the timeout of the first test is still pending and will also fire.

To fix it run jest.clearAllTimers in afterEach hook.

afterEach(() => {
  jest.clearAllMocks();
  jest.clearAllTimers();
});

โณ Promises

Not awaiting promises can cause unpredictable timing and flaky tests.

const mutateGlobalState = async () => {
  localStorage.setItem('status', 'pending');
  await doSomething();
  localStorage.setItem('status', 'done');
};

beforeEach(() => {
  localStorage.removeItem('status');
});

it('should have awaited the promise', () => {
  // ups forgot to await the promise
  mutateGlobalState();
  expect(localStorage.getItem('status')).toEqual('pending');
});

Now it is not clear at which point in time the promise will resolve and set the value which can result in flaky tests.

To fix it simply await the function.

it('awaits the promise', async () => {
  await mutateGlobalState();
  expect(localStorage.getItem('status')).toEqual('pending');
});

๐Ÿ–จ๏ธ Console Output

Unexpected console warnings or errors may indicate hidden issues.

import { render, screen } from '@testing-library/react';

it('leaks console.error', () => {
  const MyComponent = () => <div wrongProp={'hello'}>hello</div>;
  render(<MyComponent />);
  expect(screen.getByText('hello')).toBeInTheDocument();
});

React logs an error due to an invalid prop, but the test still passes, hiding the issue.

๐Ÿ‘‚ Window listener

Adding global listeners without a cleanup can leak state or throw errors in later tests.

import { renderHook, act } from '@testing-library/react';
import { useState, useEffect } from 'react';

const calculateViewport = () => {
  const width = window.innerWidth;
  if (width < 600) return 'sm';
  if (width < 1200) return 'm';
  return 'xl';
};

const useViewport = () => {
  const [viewport, setViewport] = useState('sm');

  useEffect(() => {
    window.addEventListener('resize', () => {
      setViewport(calculateViewport());
    });
  }, []);

  return viewport;
};

it('should set the viewport on window changes', () => {
  const { result } = renderHook(useViewport);

  expect(result.current).toEqual('sm');

  act(() => {
    window.innerWidth = 500;
    window.dispatchEvent(new Event('resize'));
  });

  expect(result.current).toEqual('m');
});

To fix the issue, simple return a cleanup function inside useEffect:

useEffect(() => {
  const handler = (event) => {
    setViewport(calculateViewport(event));
  };

  window.addEventListener('resize', handler);

  return () => {
    window.removeEventListener('resize', handler);
  };
}, []);

Usually rule of thumb if you are using a useEffect there should be a cleanup function, there are only some rare exceptions to it. Most useEffect can be converted to useMemo

๐Ÿ“Œ Conclusion