Skip to content

Instantly share code, notes, and snippets.

@kapral18
Created September 8, 2025 19:06
Show Gist options
  • Save kapral18/eed433b6ad35d5b124f5bd8c7959e914 to your computer and use it in GitHub Desktop.
Save kapral18/eed433b6ad35d5b124f5bd8c7959e914 to your computer and use it in GitHub Desktop.

Revisions

  1. kapral18 created this gist Sep 8, 2025.
    154 changes: 154 additions & 0 deletions fakeTimers.vs.waitFor.test.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,154 @@
    /*
    * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
    * or more contributor license agreements. Licensed under the Elastic License
    * 2.0; you may not use this file except in compliance with the Elastic License
    * 2.0.
    */

    import { waitFor, waitForElementToBeRemoved, screen, render, act } from '@testing-library/react';
    import userEvent from '@testing-library/user-event';
    import React from 'react';

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

    describe('Basic scenario', () => {
    beforeEach(() => {
    simpleTimer.callCount = 0;
    });

    async function simpleTimer(callback: any) {
    simpleTimer.callCount += 1;

    if (simpleTimer.callCount > 4) {
    return;
    }

    // await is the microtask boundary before scheduling the next timer
    // jest fake timer calls do not advance the microtask queue
    // only macrotasks (setTimeout, setInterval) are advanced
    // even if you use runAllTimers or advanceTimersByTime
    // so the await here means the next setTimeout is never scheduled
    await callback();
    setTimeout(() => {
    simpleTimer(callback);
    }, 1000);
    }

    simpleTimer.callCount = 0;

    const callback = jest.fn();

    it('fakeTimers dont work well with promise microtasks queues', async () => {
    jest.useFakeTimers();

    await simpleTimer(callback);
    jest.advanceTimersByTime(4000);

    expect(callback).toHaveBeenCalledTimes(4);

    jest.runOnlyPendingTimers();
    jest.useRealTimers();
    });

    it('real timers with waitFor work fine with microtasks', async () => {
    await simpleTimer(callback);

    await waitFor(() => {
    expect(callback).toHaveBeenCalledTimes(4);
    });
    });
    });

    describe('Component scenarios', () => {
    const api = {
    save: jest.fn().mockResolvedValue(null),
    };

    function AutoSaveForm() {
    const [status, setStatus] = React.useState<'idle' | 'saving' | 'saved'>('idle');

    async function onChange(value: string) {
    setStatus('saving');
    await api.save(value); // microtask boundary before scheduling the next timer
    setStatus('saved');
    setTimeout(() => setStatus('idle'), 1000);
    }

    return (
    <>
    <label htmlFor="name">Name</label>
    <input id="name" onChange={(e) => onChange(e.target.value)} />
    {status !== 'idle' && <output>{status}</output>}
    </>
    );
    }

    describe('Failures with fake timers', () => {
    beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();
    });

    afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
    });

    it('shows and hides saved toasts', async () => {
    render(<AutoSaveForm />);

    await userEvent.type(screen.getByLabelText('Name', { selector: 'input' }), 'abc');

    act(() => {
    jest.runAllTimers();
    });

    // Still here: because microtasks are not handled
    expect(screen.getByRole('status')).toHaveTextContent(/saved/i);
    });

    it('shows and hides saved toasts (fake timers + waitForElementToBeRemoved)', async () => {
    render(<AutoSaveForm />);

    await userEvent.type(screen.getByLabelText('Name', { selector: 'input' }), 'abc');

    act(() => {
    jest.runAllTimers();
    });

    // Even waiting for it to be removed times out
    // Because fake timers mess up the async scheduling
    await waitForElementToBeRemoved(() => screen.queryByRole('status')); // times out
    });

    it('shows and hides saved toats (fake timers + waitFor)', async () => {
    render(<AutoSaveForm />);

    await userEvent.type(screen.getByLabelText('Name', { selector: 'input' }), 'abc');

    act(() => {
    jest.runAllTimers();
    });

    // Even waitFor(() => ...) times out when paired with fake timers
    // Because of the same reason as above
    await waitFor(() => expect(screen.getByRole('status')).not.toHaveTextContent(/saved/i));
    });
    });

    describe('Success', () => {
    it('shows and hides saved toast after async save (real timers)', async () => {
    render(<AutoSaveForm />);

    await userEvent.type(screen.getByLabelText('Name', { selector: 'input' }), 'abc');

    // or waitFor(() => expect(screen.getByText('saved', { selector: 'output' })).toBeInTheDocument())
    // same thing
    expect(await screen.findByText('saved', { selector: 'output' })).toBeInTheDocument();

    await waitForElementToBeRemoved(() => screen.queryByRole('status'));
    });
    });
    });