From 4597866ea60dbb6e2ee9b0e2b128f9788cef2cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 15:48:54 +0100 Subject: [PATCH 1/9] . --- src/__tests__/{wait-for.test.tsx => wait-for-old.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/{wait-for.test.tsx => wait-for-old.test.tsx} (100%) diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for-old.test.tsx similarity index 100% rename from src/__tests__/wait-for.test.tsx rename to src/__tests__/wait-for-old.test.tsx From e1d5730c45deb5bfd21b09298ce324aeff753b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 15:06:28 +0100 Subject: [PATCH 2/9] basic test --- src/__tests__/wait-for.test.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/__tests__/wait-for.test.tsx diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx new file mode 100644 index 000000000..c2e910685 --- /dev/null +++ b/src/__tests__/wait-for.test.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import { render, screen, waitFor } from '..'; + +test('waits for query', async () => { + function AsyncComponent() { + const [text, setText] = React.useState('Loading...'); + + React.useEffect(() => { + setTimeout(() => setText('Loaded'), 100); + }, []); + + return {text}; + } + + await render(); + await waitFor(() => screen.getByText('Loaded')); + expect(screen.getByText('Loaded')).toBeOnTheScreen(); +}); From ef09df77a5dd7460307ff3d7fd1227e3a643e6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 15:11:19 +0100 Subject: [PATCH 3/9] also test fake timers 2nd . wait for + expect returned error thows on not a funciton switching timer types . not ready yet never true fake timers throws generic timeout error when promise rejects with falsy value until timeout --- src/__tests__/wait-for.test.tsx | 146 +++++++++++++++++++++++++++++++- src/test-utils/timers.ts | 11 +++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/test-utils/timers.ts diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index c2e910685..af282c75c 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -2,8 +2,33 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen, waitFor } from '..'; +import { useTimerType } from '../test-utils/timers'; -test('waits for query', async () => { +beforeEach(() => { + jest.useRealTimers(); +}); + +test('waits for expect() assertion to pass', async () => { + const mockFunction = jest.fn(); + + function AsyncComponent() { + React.useEffect(() => { + setTimeout(() => mockFunction(), 100); + }, []); + + return ; + } + + await render(); + await waitFor(() => expect(mockFunction).toHaveBeenCalled()); + expect(mockFunction).toHaveBeenCalledTimes(1); +}); + +test.each([ + { timerType: 'real' as const }, + { timerType: 'fake' as const }, + { timerType: 'fake-legacy' as const }, +])('waits for query with $timerType timers', async ({ timerType }) => { function AsyncComponent() { const [text, setText] = React.useState('Loading...'); @@ -14,7 +39,126 @@ test('waits for query', async () => { return {text}; } + useTimerType(timerType); await render(); await waitFor(() => screen.getByText('Loaded')); expect(screen.getByText('Loaded')).toBeOnTheScreen(); }); + +test('throws timeout error when condition never becomes true', async () => { + function Component() { + return Hello; + } + + await render(); + await expect(waitFor(() => screen.getByText('Never appears'), { timeout: 100 })).rejects.toThrow( + 'Unable to find an element with text: Never appears', + ); +}); + +test('uses custom error from onTimeout callback when timeout occurs', async () => { + const customErrorMessage = 'Custom timeout error: Element never appeared'; + + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => new Error(customErrorMessage), + }), + ).rejects.toThrow(customErrorMessage); +}); + +test('throws TypeError when expectation is not a function', async () => { + await expect(waitFor(null as any)).rejects.toThrow( + 'Received `expectation` arg must be a function', + ); +}); + +test('throws error when switching from real timers to fake timers during waitFor', async () => { + await render(); + + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); + }); + + // Switch to fake timers while waitFor is running + jest.useFakeTimers(); + + await expect(waitForPromise).rejects.toThrow( + 'Changed from using real timers to fake timers while using waitFor', + ); +}); + +test('throws error when switching from fake timers to real timers during waitFor', async () => { + jest.useFakeTimers(); + await render(); + + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); + }); + + // Switch to real timers while waitFor is running + jest.useRealTimers(); + + await expect(waitForPromise).rejects.toThrow( + 'Changed from using fake timers to real timers while using waitFor', + ); +}); + +test('converts non-Error thrown value to Error when timeout occurs', async () => { + const errorMessage = 'Custom string error'; + + let caughtError: unknown; + try { + await waitFor( + () => { + throw errorMessage; + }, + { timeout: 50 }, + ); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe(errorMessage); +}); + +test('continues waiting when expectation returns a promise that rejects', async () => { + let attemptCount = 0; + const maxAttempts = 3; + + await waitFor( + () => { + attemptCount++; + if (attemptCount < maxAttempts) { + return Promise.reject(new Error('Not ready yet')); + } + return Promise.resolve('Success'); + }, + { timeout: 1000 }, + ); + + expect(attemptCount).toBe(maxAttempts); +}); + +test('throws timeout error with fake timers when condition never becomes true', async () => { + jest.useFakeTimers(); + await render(); + + const waitForPromise = waitFor(() => screen.getByText('Never appears'), { timeout: 100 }); + + await jest.advanceTimersByTimeAsync(100); + + await expect(waitForPromise).rejects.toThrow('Unable to find an element with text: Never appears'); +}); + +test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { + await expect( + waitFor(() => Promise.reject(null), { + timeout: 100, + }), + ).rejects.toThrow('Timed out in waitFor.'); +}); diff --git a/src/test-utils/timers.ts b/src/test-utils/timers.ts new file mode 100644 index 000000000..a4955fad7 --- /dev/null +++ b/src/test-utils/timers.ts @@ -0,0 +1,11 @@ +export type TimerType = 'real' | 'fake' | 'fake-legacy'; + +export function useTimerType(type: TimerType): void { + if (type === 'fake-legacy') { + jest.useFakeTimers({ legacyFakeTimers: true }); + } else if (type === 'fake') { + jest.useFakeTimers({ legacyFakeTimers: false }); + } else { + jest.useRealTimers(); + } +} From 70ca5eefc2ce53bbc53ada6450407d8428e98b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 15:52:50 +0100 Subject: [PATCH 4/9] migrate old tests --- src/__tests__/wait-for.test.tsx | 257 +++++++++++++++++++++++++++++++- 1 file changed, 255 insertions(+), 2 deletions(-) diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index af282c75c..2c002c492 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Text, View } from 'react-native'; +import { Pressable, Text, TouchableOpacity, View } from 'react-native'; -import { render, screen, waitFor } from '..'; +import { configure, fireEvent, render, screen, waitFor } from '..'; import { useTimerType } from '../test-utils/timers'; beforeEach(() => { @@ -162,3 +162,256 @@ test('throws generic timeout error when promise rejects with falsy value until t }), ).rejects.toThrow('Timed out in waitFor.'); }); + +test('waits for element with custom interval', async () => { + const mockFn = jest.fn(() => { + throw Error('test'); + }); + + try { + await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); + } catch { + // suppress + } + + expect(mockFn).toHaveBeenCalledTimes(2); +}); + +test('waitFor defaults to asyncUtilTimeout config option', async () => { + class BananaContainer extends React.Component { + state = { fresh: false }; + + onChangeFresh = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + this.setState({ fresh: true }); + }; + + render() { + return ( + + {this.state.fresh && Fresh} + + Change freshness! + + + ); + } + } + + configure({ asyncUtilTimeout: 100 }); + await render(); + + fireEvent.press(screen.getByText('Change freshness!')); + + expect(screen.queryByText('Fresh')).toBeNull(); + + await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); + + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Fresh'), { timeout: 1000 }); +}); + +test('waitFor timeout option takes precedence over asyncUtilTimeout config option', async () => { + class BananaContainer extends React.Component { + state = { fresh: false }; + + onChangeFresh = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + this.setState({ fresh: true }); + }; + + render() { + return ( + + {this.state.fresh && Fresh} + + Change freshness! + + + ); + } + } + + configure({ asyncUtilTimeout: 2000 }); + await render(); + + fireEvent.press(screen.getByText('Change freshness!')); + + expect(screen.queryByText('Fresh')).toBeNull(); + + await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); + + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Fresh')); +}); + +test('waits for async event with fireEvent', async () => { + const Comp = ({ onPress }: { onPress: () => void }) => { + const [state, setState] = React.useState(false); + + React.useEffect(() => { + if (state) { + onPress(); + } + }, [state, onPress]); + + return ( + { + await Promise.resolve(); + setState(true); + }} + > + Trigger + + ); + }; + + const spy = jest.fn(); + await render(); + + await fireEvent.press(screen.getByText('Trigger')); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); +}); + +test.each([ + [false, false], + [true, false], + [true, true], +])( + 'flushes scheduled updates before returning (fakeTimers = %s, legacyFakeTimers = %s)', + async (fakeTimers, legacyFakeTimers) => { + if (fakeTimers) { + jest.useFakeTimers({ legacyFakeTimers }); + } + + function Apple({ onPress }: { onPress: (color: string) => void }) { + const [color, setColor] = React.useState('green'); + const [syncedColor, setSyncedColor] = React.useState(color); + + // On mount, set the color to "red" in a promise microtask + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then + Promise.resolve('red').then((c) => setColor(c)); + }, []); + + // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect + React.useEffect(() => { + setSyncedColor(color); + }, [color]); + + return ( + + {color} + onPress(syncedColor)}> + Trigger + + + ); + } + + const onPress = jest.fn(); + await render(); + + // Required: this `waitFor` will succeed on first check, because the "root" view is there + // since the initial mount. + await waitFor(() => screen.getByTestId('root')); + + // This `waitFor` will also succeed on first check, because the promise that sets the + // `color` state to "red" resolves right after the previous `await waitFor` statement. + await waitFor(() => screen.getByText('red')); + + // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledWith('red'); + }, +); + +test.each([true, false])( + 'it should not depend on real time when using fake timers (legacyFakeTimers = %s)', + async (legacyFakeTimers) => { + jest.useFakeTimers({ legacyFakeTimers }); + const WAIT_FOR_INTERVAL = 20; + const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; + + const blockThread = (timeToBlockThread: number, legacyFakeTimers: boolean) => { + jest.useRealTimers(); + const end = Date.now() + timeToBlockThread; + + while (Date.now() < end) { + // do nothing + } + + jest.useFakeTimers({ legacyFakeTimers }); + }; + + const mockErrorFn = jest.fn(() => { + // Wait 2 times interval so that check time is longer than interval + blockThread(WAIT_FOR_INTERVAL * 2, legacyFakeTimers); + throw new Error('test'); + }); + + await expect( + async () => + await waitFor(mockErrorFn, { + timeout: WAIT_FOR_TIMEOUT, + interval: WAIT_FOR_INTERVAL, + }), + ).rejects.toThrow(); + + // Verify that the `waitFor` callback has been called the expected number of times + // (timeout / interval + 1), so it confirms that the real duration of callback did not + // cause the real clock timeout when running using fake timers. + expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); + }, +); + +test.each([false, true])( + 'waits for assertion until timeout is met with fake timers and interval (legacyFakeTimers = %s)', + async (legacyFakeTimers) => { + jest.useFakeTimers({ legacyFakeTimers }); + + const mockFn = jest.fn(() => { + throw Error('test'); + }); + + try { + await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); + } catch { + // suppress + } + + expect(mockFn).toHaveBeenCalledTimes(3); + }, +); + +test.each([false, true])( + 'waits for assertion until timeout is met with fake timers, interval, and onTimeout (legacyFakeTimers = %s)', + async (legacyFakeTimers) => { + jest.useFakeTimers({ legacyFakeTimers }); + + const mockErrorFn = jest.fn(() => { + throw Error('test'); + }); + + const mockHandleFn = jest.fn((e) => e); + + try { + await waitFor(() => mockErrorFn(), { + timeout: 400, + interval: 200, + onTimeout: mockHandleFn, + }); + } catch { + // suppress + } + + expect(mockErrorFn).toHaveBeenCalledTimes(3); + expect(mockHandleFn).toHaveBeenCalledTimes(1); + }, +); From 8ec5f2b6de4fbc53c627cb0b8f18d30658b845bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 17:29:25 +0100 Subject: [PATCH 5/9] tweaks --- src/__tests__/wait-for.test.tsx | 179 +++++++++++++-------------- src/helpers/__tests__/errors.test.ts | 12 +- src/helpers/errors.ts | 4 +- src/test-utils/timers.ts | 2 +- src/wait-for.ts | 24 ++-- 5 files changed, 106 insertions(+), 115 deletions(-) diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 2c002c492..3f4a0987a 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Pressable, Text, TouchableOpacity, View } from 'react-native'; import { configure, fireEvent, render, screen, waitFor } from '..'; -import { useTimerType } from '../test-utils/timers'; +import { setupTimeType, TimerType } from '../test-utils/timers'; beforeEach(() => { jest.useRealTimers(); @@ -24,26 +24,26 @@ test('waits for expect() assertion to pass', async () => { expect(mockFunction).toHaveBeenCalledTimes(1); }); -test.each([ - { timerType: 'real' as const }, - { timerType: 'fake' as const }, - { timerType: 'fake-legacy' as const }, -])('waits for query with $timerType timers', async ({ timerType }) => { - function AsyncComponent() { - const [text, setText] = React.useState('Loading...'); +test.each(['real', 'fake', 'fake-legacy'] as const)( + 'waits for query with %s timers', + async (timerType) => { + setupTimeType(timerType as TimerType); + function AsyncComponent() { + const [text, setText] = React.useState('Loading...'); - React.useEffect(() => { - setTimeout(() => setText('Loaded'), 100); - }, []); + React.useEffect(() => { + setTimeout(() => setText('Loaded'), 100); + }, []); - return {text}; - } + return {text}; + } - useTimerType(timerType); - await render(); - await waitFor(() => screen.getByText('Loaded')); - expect(screen.getByText('Loaded')).toBeOnTheScreen(); -}); + setupTimeType(timerType); + await render(); + await waitFor(() => screen.getByText('Loaded')); + expect(screen.getByText('Loaded')).toBeOnTheScreen(); + }, +); test('throws timeout error when condition never becomes true', async () => { function Component() { @@ -68,6 +68,17 @@ test('uses custom error from onTimeout callback when timeout occurs', async () = ).rejects.toThrow(customErrorMessage); }); +test('onTimeout callback returning falsy value keeps original error', async () => { + await render(); + // When onTimeout returns null/undefined/false, the original error should be kept (line 181 false branch) + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => null as any, + }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); +}); + test('throws TypeError when expectation is not a function', async () => { await expect(waitFor(null as any)).rejects.toThrow( 'Received `expectation` arg must be a function', @@ -152,7 +163,9 @@ test('throws timeout error with fake timers when condition never becomes true', await jest.advanceTimersByTimeAsync(100); - await expect(waitForPromise).rejects.toThrow('Unable to find an element with text: Never appears'); + await expect(waitForPromise).rejects.toThrow( + 'Unable to find an element with text: Never appears', + ); }); test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { @@ -178,73 +191,61 @@ test('waits for element with custom interval', async () => { }); test('waitFor defaults to asyncUtilTimeout config option', async () => { - class BananaContainer extends React.Component { - state = { fresh: false }; + function Component() { + const [active, setActive] = React.useState(false); - onChangeFresh = async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - this.setState({ fresh: true }); + const handlePress = () => { + setTimeout(() => setActive(true), 300); }; - render() { - return ( - - {this.state.fresh && Fresh} - - Change freshness! - - - ); - } + return ( + + {active && Active} + + Activate + + + ); } configure({ asyncUtilTimeout: 100 }); - await render(); - - fireEvent.press(screen.getByText('Change freshness!')); - - expect(screen.queryByText('Fresh')).toBeNull(); - - await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'))).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh'), { timeout: 1000 }); + await waitFor(() => screen.getByText('Active'), { timeout: 1000 }); }); test('waitFor timeout option takes precedence over asyncUtilTimeout config option', async () => { - class BananaContainer extends React.Component { - state = { fresh: false }; + function AsyncTextToggle() { + const [active, setActive] = React.useState(false); - onChangeFresh = async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - this.setState({ fresh: true }); + const handlePress = () => { + setTimeout(() => setActive(true), 300); }; - render() { - return ( - - {this.state.fresh && Fresh} - - Change freshness! - - - ); - } + return ( + + {active && Active} + + Activate + + + ); } configure({ asyncUtilTimeout: 2000 }); - await render(); - - fireEvent.press(screen.getByText('Change freshness!')); - - expect(screen.queryByText('Fresh')).toBeNull(); - - await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'), { timeout: 100 })).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh')); + await waitFor(() => screen.getByText('Active')); }); test('waits for async event with fireEvent', async () => { @@ -279,18 +280,12 @@ test('waits for async event with fireEvent', async () => { }); }); -test.each([ - [false, false], - [true, false], - [true, true], -])( - 'flushes scheduled updates before returning (fakeTimers = %s, legacyFakeTimers = %s)', - async (fakeTimers, legacyFakeTimers) => { - if (fakeTimers) { - jest.useFakeTimers({ legacyFakeTimers }); - } +test.each(['real', 'fake', 'fake-legacy'] as const)( + 'flushes scheduled updates before returning (timerType = %s)', + async (timerType) => { + setupTimeType(timerType); - function Apple({ onPress }: { onPress: (color: string) => void }) { + function Component({ onPress }: { onPress: (color: string) => void }) { const [color, setColor] = React.useState('green'); const [syncedColor, setSyncedColor] = React.useState(color); @@ -316,7 +311,7 @@ test.each([ } const onPress = jest.fn(); - await render(); + await render(); // Required: this `waitFor` will succeed on first check, because the "root" view is there // since the initial mount. @@ -332,14 +327,14 @@ test.each([ }, ); -test.each([true, false])( - 'it should not depend on real time when using fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); +test.each(['fake', 'fake-legacy'] as const)( + 'it should not depend on real time when using %s timers', + async (timerType) => { + setupTimeType(timerType); const WAIT_FOR_INTERVAL = 20; const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; - const blockThread = (timeToBlockThread: number, legacyFakeTimers: boolean) => { + const blockThread = (timeToBlockThread: number, timerType: TimerType): void => { jest.useRealTimers(); const end = Date.now() + timeToBlockThread; @@ -347,12 +342,12 @@ test.each([true, false])( // do nothing } - jest.useFakeTimers({ legacyFakeTimers }); + setupTimeType(timerType); }; const mockErrorFn = jest.fn(() => { // Wait 2 times interval so that check time is longer than interval - blockThread(WAIT_FOR_INTERVAL * 2, legacyFakeTimers); + blockThread(WAIT_FOR_INTERVAL * 2, timerType); throw new Error('test'); }); @@ -371,10 +366,10 @@ test.each([true, false])( }, ); -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers and interval (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); +test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers and interval', + async (timerType) => { + setupTimeType(timerType); const mockFn = jest.fn(() => { throw Error('test'); @@ -390,10 +385,10 @@ test.each([false, true])( }, ); -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers, interval, and onTimeout (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); +test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers, interval, and onTimeout', + async (timerType) => { + setupTimeType(timerType); const mockErrorFn = jest.fn(() => { throw Error('test'); diff --git a/src/helpers/__tests__/errors.test.ts b/src/helpers/__tests__/errors.test.ts index 09fc7abe7..232527458 100644 --- a/src/helpers/__tests__/errors.test.ts +++ b/src/helpers/__tests__/errors.test.ts @@ -1,4 +1,4 @@ -import { copyStackTrace, ErrorWithStack } from '../errors'; +import { copyStackTraceIfNeeded, ErrorWithStack } from '../errors'; describe('ErrorWithStack', () => { test('should create an error with message', () => { @@ -35,7 +35,7 @@ describe('copyStackTrace', () => { const source = new Error('Source error'); source.stack = 'Error: Source error\n at test.js:1:1'; - copyStackTrace(target, source); + copyStackTraceIfNeeded(target, source); expect(target.stack).toBe('Error: Target error\n at test.js:1:1'); const target2 = new Error('Target error'); @@ -43,7 +43,7 @@ describe('copyStackTrace', () => { source2.stack = 'Error: Source error\n at test.js:1:1\nError: Source error\n at test.js:2:2'; - copyStackTrace(target2, source2); + copyStackTraceIfNeeded(target2, source2); // Should replace only the first occurrence expect(target2.stack).toBe( 'Error: Target error\n at test.js:1:1\nError: Source error\n at test.js:2:2', @@ -55,20 +55,20 @@ describe('copyStackTrace', () => { const source = new Error('Source error'); source.stack = 'Error: Source error\n at test.js:1:1'; - copyStackTrace(targetNotError, source); + copyStackTraceIfNeeded(targetNotError, source); expect(targetNotError).toEqual({ message: 'Not an error' }); const target = new Error('Target error'); const originalStack = target.stack; const sourceNotError = { message: 'Not an error' }; - copyStackTrace(target, sourceNotError as Error); + copyStackTraceIfNeeded(target, sourceNotError as Error); expect(target.stack).toBe(originalStack); const sourceNoStack = new Error('Source error'); delete sourceNoStack.stack; - copyStackTrace(target, sourceNoStack); + copyStackTraceIfNeeded(target, sourceNoStack); expect(target.stack).toBe(originalStack); }); }); diff --git a/src/helpers/errors.ts b/src/helpers/errors.ts index a6965619c..20c56a747 100644 --- a/src/helpers/errors.ts +++ b/src/helpers/errors.ts @@ -8,8 +8,8 @@ export class ErrorWithStack extends Error { } } -export function copyStackTrace(target: unknown, stackTraceSource: Error) { - if (target instanceof Error && stackTraceSource.stack) { +export function copyStackTraceIfNeeded(target: unknown, stackTraceSource: Error | undefined) { + if (stackTraceSource != null && target instanceof Error && stackTraceSource.stack) { target.stack = stackTraceSource.stack.replace(stackTraceSource.message, target.message); } } diff --git a/src/test-utils/timers.ts b/src/test-utils/timers.ts index a4955fad7..78bd46538 100644 --- a/src/test-utils/timers.ts +++ b/src/test-utils/timers.ts @@ -1,6 +1,6 @@ export type TimerType = 'real' | 'fake' | 'fake-legacy'; -export function useTimerType(type: TimerType): void { +export function setupTimeType(type: TimerType): void { if (type === 'fake-legacy') { jest.useFakeTimers({ legacyFakeTimers: true }); } else if (type === 'fake') { diff --git a/src/wait-for.ts b/src/wait-for.ts index 409623a8c..29ad802b4 100644 --- a/src/wait-for.ts +++ b/src/wait-for.ts @@ -2,7 +2,7 @@ import { act } from './act'; import { getConfig } from './config'; import { flushMicroTasks } from './flush-micro-tasks'; -import { copyStackTrace, ErrorWithStack } from './helpers/errors'; +import { copyStackTraceIfNeeded, ErrorWithStack } from './helpers/errors'; import { clearTimeout, getJestFakeTimersType, @@ -55,9 +55,7 @@ function waitForInternal( const error = new Error( `Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`, ); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); reject(error); return; } @@ -121,9 +119,7 @@ function waitForInternal( const error = new Error( `Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`, ); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); return reject(error); } else { return checkExpectation(); @@ -131,7 +127,11 @@ function waitForInternal( } function checkExpectation() { - if (promiseStatus === 'pending') return; + /* istanbul ignore next */ + if (promiseStatus === 'pending') { + return; + } + try { const result = expectation(); @@ -171,14 +171,10 @@ function waitForInternal( error = new Error(String(lastError)); } - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); } else { error = new Error('Timed out in waitFor.'); - if (stackTraceError) { - copyStackTrace(error, stackTraceError); - } + copyStackTraceIfNeeded(error, stackTraceError); } if (typeof onTimeout === 'function') { const result = onTimeout(error); From facdc004a6dc853299fda1cc015444f641fac9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 17:39:50 +0100 Subject: [PATCH 6/9] . --- src/__tests__/wait-for.test.tsx | 639 ++++++++++++++++---------------- 1 file changed, 324 insertions(+), 315 deletions(-) diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 3f4a0987a..03caed81c 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -2,411 +2,420 @@ import * as React from 'react'; import { Pressable, Text, TouchableOpacity, View } from 'react-native'; import { configure, fireEvent, render, screen, waitFor } from '..'; -import { setupTimeType, TimerType } from '../test-utils/timers'; +import type { TimerType } from '../test-utils/timers'; +import { setupTimeType } from '../test-utils/timers'; beforeEach(() => { jest.useRealTimers(); }); -test('waits for expect() assertion to pass', async () => { - const mockFunction = jest.fn(); +describe('successful waiting', () => { + test('waits for expect() assertion to pass', async () => { + const mockFunction = jest.fn(); - function AsyncComponent() { - React.useEffect(() => { - setTimeout(() => mockFunction(), 100); - }, []); - - return ; - } - - await render(); - await waitFor(() => expect(mockFunction).toHaveBeenCalled()); - expect(mockFunction).toHaveBeenCalledTimes(1); -}); - -test.each(['real', 'fake', 'fake-legacy'] as const)( - 'waits for query with %s timers', - async (timerType) => { - setupTimeType(timerType as TimerType); function AsyncComponent() { - const [text, setText] = React.useState('Loading...'); - React.useEffect(() => { - setTimeout(() => setText('Loaded'), 100); + setTimeout(() => mockFunction(), 100); }, []); - return {text}; + return ; } - setupTimeType(timerType); await render(); - await waitFor(() => screen.getByText('Loaded')); - expect(screen.getByText('Loaded')).toBeOnTheScreen(); - }, -); - -test('throws timeout error when condition never becomes true', async () => { - function Component() { - return Hello; - } - - await render(); - await expect(waitFor(() => screen.getByText('Never appears'), { timeout: 100 })).rejects.toThrow( - 'Unable to find an element with text: Never appears', - ); -}); + await waitFor(() => expect(mockFunction).toHaveBeenCalled()); + expect(mockFunction).toHaveBeenCalledTimes(1); + }); -test('uses custom error from onTimeout callback when timeout occurs', async () => { - const customErrorMessage = 'Custom timeout error: Element never appeared'; + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'waits for query with %s timers', + async (timerType) => { + setupTimeType(timerType as TimerType); + function AsyncComponent() { + const [text, setText] = React.useState('Loading...'); - await render(); - await expect( - waitFor(() => screen.getByText('Never appears'), { - timeout: 100, - onTimeout: () => new Error(customErrorMessage), - }), - ).rejects.toThrow(customErrorMessage); -}); + React.useEffect(() => { + setTimeout(() => setText('Loaded'), 100); + }, []); -test('onTimeout callback returning falsy value keeps original error', async () => { - await render(); - // When onTimeout returns null/undefined/false, the original error should be kept (line 181 false branch) - await expect( - waitFor(() => screen.getByText('Never appears'), { - timeout: 100, - onTimeout: () => null as any, - }), - ).rejects.toThrow('Unable to find an element with text: Never appears'); -}); + return {text}; + } -test('throws TypeError when expectation is not a function', async () => { - await expect(waitFor(null as any)).rejects.toThrow( - 'Received `expectation` arg must be a function', + setupTimeType(timerType); + await render(); + await waitFor(() => screen.getByText('Loaded')); + expect(screen.getByText('Loaded')).toBeOnTheScreen(); + }, ); -}); -test('throws error when switching from real timers to fake timers during waitFor', async () => { - await render(); + test('waits for async event with fireEvent', async () => { + const Component = ({ onDelayedPress }: { onDelayedPress: () => void }) => { + const [state, setState] = React.useState(false); + + React.useEffect(() => { + if (state) { + onDelayedPress(); + } + }, [state, onDelayedPress]); + + return ( + { + await Promise.resolve(); + setState(true); + }} + > + Press + + ); + }; + + const onDelayedPress = jest.fn(); + await render(); - const waitForPromise = waitFor(() => { - // This will never pass, but we'll switch timers before timeout - return screen.getByText('Never appears'); + await fireEvent.press(screen.getByText('Press')); + await waitFor(() => { + expect(onDelayedPress).toHaveBeenCalled(); + }); }); - // Switch to fake timers while waitFor is running - jest.useFakeTimers(); + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'flushes scheduled updates before returning with %s timers', + async (timerType) => { + setupTimeType(timerType); - await expect(waitForPromise).rejects.toThrow( - 'Changed from using real timers to fake timers while using waitFor', - ); -}); + function Component({ onPress }: { onPress: (color: string) => void }) { + const [color, setColor] = React.useState('green'); + const [syncedColor, setSyncedColor] = React.useState(color); + + // On mount, set the color to "red" in a promise microtask + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then + Promise.resolve('red').then((c) => setColor(c)); + }, []); + + // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect + React.useEffect(() => { + setSyncedColor(color); + }, [color]); + + return ( + + {color} + onPress(syncedColor)}> + Trigger + + + ); + } -test('throws error when switching from fake timers to real timers during waitFor', async () => { - jest.useFakeTimers(); - await render(); + const onPress = jest.fn(); + await render(); - const waitForPromise = waitFor(() => { - // This will never pass, but we'll switch timers before timeout - return screen.getByText('Never appears'); - }); + // Required: this `waitFor` will succeed on first check, because the "root" view is there + // since the initial mount. + await waitFor(() => screen.getByTestId('root')); - // Switch to real timers while waitFor is running - jest.useRealTimers(); + // This `waitFor` will also succeed on first check, because the promise that sets the + // `color` state to "red" resolves right after the previous `await waitFor` statement. + await waitFor(() => screen.getByText('red')); - await expect(waitForPromise).rejects.toThrow( - 'Changed from using fake timers to real timers while using waitFor', + // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledWith('red'); + }, ); -}); -test('converts non-Error thrown value to Error when timeout occurs', async () => { - const errorMessage = 'Custom string error'; + test('continues waiting when expectation returns a promise that rejects', async () => { + let attemptCount = 0; + const maxAttempts = 3; - let caughtError: unknown; - try { await waitFor( () => { - throw errorMessage; + attemptCount++; + if (attemptCount < maxAttempts) { + return Promise.reject(new Error('Not ready yet')); + } + return Promise.resolve('Success'); }, - { timeout: 50 }, + { timeout: 1000 }, ); - } catch (error) { - caughtError = error; - } - expect(caughtError).toBeInstanceOf(Error); - expect((caughtError as Error).message).toBe(errorMessage); + expect(attemptCount).toBe(maxAttempts); + }); }); -test('continues waiting when expectation returns a promise that rejects', async () => { - let attemptCount = 0; - const maxAttempts = 3; - - await waitFor( - () => { - attemptCount++; - if (attemptCount < maxAttempts) { - return Promise.reject(new Error('Not ready yet')); - } - return Promise.resolve('Success'); - }, - { timeout: 1000 }, - ); +describe('timeout errors', () => { + test('throws timeout error when condition never becomes true', async () => { + function Component() { + return Hello; + } - expect(attemptCount).toBe(maxAttempts); -}); + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { timeout: 100 }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); + }); -test('throws timeout error with fake timers when condition never becomes true', async () => { - jest.useFakeTimers(); - await render(); + test('throws timeout error with fake timers when condition never becomes true', async () => { + jest.useFakeTimers(); + await render(); - const waitForPromise = waitFor(() => screen.getByText('Never appears'), { timeout: 100 }); + const waitForPromise = waitFor(() => screen.getByText('Never appears'), { timeout: 100 }); - await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(100); + await expect(waitForPromise).rejects.toThrow( + 'Unable to find an element with text: Never appears', + ); + }); - await expect(waitForPromise).rejects.toThrow( - 'Unable to find an element with text: Never appears', - ); -}); + test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { + await expect( + waitFor(() => Promise.reject(null), { + timeout: 100, + }), + ).rejects.toThrow('Timed out in waitFor.'); + }); -test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { - await expect( - waitFor(() => Promise.reject(null), { - timeout: 100, - }), - ).rejects.toThrow('Timed out in waitFor.'); -}); + test('uses custom error from onTimeout callback when timeout occurs', async () => { + const customErrorMessage = 'Custom timeout error: Element never appeared'; -test('waits for element with custom interval', async () => { - const mockFn = jest.fn(() => { - throw Error('test'); + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => new Error(customErrorMessage), + }), + ).rejects.toThrow(customErrorMessage); }); - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } - - expect(mockFn).toHaveBeenCalledTimes(2); + test('onTimeout callback returning falsy value keeps original error', async () => { + await render(); + // When onTimeout returns null/undefined/false, the original error should be kept (line 181 false branch) + await expect( + waitFor(() => screen.getByText('Never appears'), { + timeout: 100, + onTimeout: () => null as any, + }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); + }); }); -test('waitFor defaults to asyncUtilTimeout config option', async () => { - function Component() { - const [active, setActive] = React.useState(false); +describe('error handling', () => { + test('throws TypeError when expectation is not a function', async () => { + await expect(waitFor(null as any)).rejects.toThrow( + 'Received `expectation` arg must be a function', + ); + }); - const handlePress = () => { - setTimeout(() => setActive(true), 300); - }; + test('converts non-Error thrown value to Error when timeout occurs', async () => { + const errorMessage = 'Custom string error'; - return ( - - {active && Active} - - Activate - - - ); - } + let caughtError: unknown; + try { + await waitFor( + () => { + throw errorMessage; + }, + { timeout: 50 }, + ); + } catch (error) { + caughtError = error; + } - configure({ asyncUtilTimeout: 100 }); - await render(); - await fireEvent.press(screen.getByText('Activate')); - expect(screen.queryByText('Active')).toBeNull(); - await expect(waitFor(() => screen.getByText('Active'))).rejects.toThrow(); + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe(errorMessage); + }); - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Active'), { timeout: 1000 }); -}); + test('throws error when switching from real timers to fake timers during waitFor', async () => { + await render(); -test('waitFor timeout option takes precedence over asyncUtilTimeout config option', async () => { - function AsyncTextToggle() { - const [active, setActive] = React.useState(false); + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); + }); - const handlePress = () => { - setTimeout(() => setActive(true), 300); - }; + // Switch to fake timers while waitFor is running + jest.useFakeTimers(); - return ( - - {active && Active} - - Activate - - + await expect(waitForPromise).rejects.toThrow( + 'Changed from using real timers to fake timers while using waitFor', ); - } + }); - configure({ asyncUtilTimeout: 2000 }); - await render(); - await fireEvent.press(screen.getByText('Activate')); - expect(screen.queryByText('Active')).toBeNull(); - await expect(waitFor(() => screen.getByText('Active'), { timeout: 100 })).rejects.toThrow(); + test('throws error when switching from fake timers to real timers during waitFor', async () => { + jest.useFakeTimers(); + await render(); - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Active')); -}); + const waitForPromise = waitFor(() => { + // This will never pass, but we'll switch timers before timeout + return screen.getByText('Never appears'); + }); -test('waits for async event with fireEvent', async () => { - const Comp = ({ onPress }: { onPress: () => void }) => { - const [state, setState] = React.useState(false); + // Switch to real timers while waitFor is running + jest.useRealTimers(); - React.useEffect(() => { - if (state) { - onPress(); - } - }, [state, onPress]); - - return ( - { - await Promise.resolve(); - setState(true); - }} - > - Trigger - + await expect(waitForPromise).rejects.toThrow( + 'Changed from using fake timers to real timers while using waitFor', ); - }; - - const spy = jest.fn(); - await render(); - - await fireEvent.press(screen.getByText('Trigger')); - - await waitFor(() => { - expect(spy).toHaveBeenCalled(); }); }); -test.each(['real', 'fake', 'fake-legacy'] as const)( - 'flushes scheduled updates before returning (timerType = %s)', - async (timerType) => { - setupTimeType(timerType); +describe('configuration', () => { + test('waits for element with custom interval', async () => { + const mockFn = jest.fn(() => { + throw Error('test'); + }); - function Component({ onPress }: { onPress: (color: string) => void }) { - const [color, setColor] = React.useState('green'); - const [syncedColor, setSyncedColor] = React.useState(color); + try { + await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); + } catch { + // suppress + } - // On mount, set the color to "red" in a promise microtask - React.useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then - Promise.resolve('red').then((c) => setColor(c)); - }, []); + expect(mockFn).toHaveBeenCalledTimes(2); + }); - // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect - React.useEffect(() => { - setSyncedColor(color); - }, [color]); + test('waitFor defaults to asyncUtilTimeout config option', async () => { + function Component() { + const [active, setActive] = React.useState(false); + + const handlePress = () => { + setTimeout(() => setActive(true), 300); + }; return ( - - {color} - onPress(syncedColor)}> - Trigger + + {active && Active} + + Activate ); } - const onPress = jest.fn(); - await render(); + configure({ asyncUtilTimeout: 100 }); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'))).rejects.toThrow(); - // Required: this `waitFor` will succeed on first check, because the "root" view is there - // since the initial mount. - await waitFor(() => screen.getByTestId('root')); + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Active'), { timeout: 1000 }); + }); - // This `waitFor` will also succeed on first check, because the promise that sets the - // `color` state to "red" resolves right after the previous `await waitFor` statement. - await waitFor(() => screen.getByText('red')); + test('waitFor timeout option takes precedence over asyncUtilTimeout config option', async () => { + function AsyncTextToggle() { + const [active, setActive] = React.useState(false); - // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledWith('red'); - }, -); + const handlePress = () => { + setTimeout(() => setActive(true), 300); + }; -test.each(['fake', 'fake-legacy'] as const)( - 'it should not depend on real time when using %s timers', - async (timerType) => { - setupTimeType(timerType); - const WAIT_FOR_INTERVAL = 20; - const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; + return ( + + {active && Active} + + Activate + + + ); + } - const blockThread = (timeToBlockThread: number, timerType: TimerType): void => { - jest.useRealTimers(); - const end = Date.now() + timeToBlockThread; + configure({ asyncUtilTimeout: 2000 }); + await render(); + await fireEvent.press(screen.getByText('Activate')); + expect(screen.queryByText('Active')).toBeNull(); + await expect(waitFor(() => screen.getByText('Active'), { timeout: 100 })).rejects.toThrow(); - while (Date.now() < end) { - // do nothing - } + // Async action ends after 300ms and we only waited 100ms, so we need to wait + // for the remaining async actions to finish + await waitFor(() => screen.getByText('Active')); + }); +}); +describe('fake timers behavior', () => { + test.each(['fake', 'fake-legacy'] as const)( + 'should not depend on real time when using %s timers', + async (timerType) => { setupTimeType(timerType); - }; + const WAIT_FOR_INTERVAL = 20; + const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; - const mockErrorFn = jest.fn(() => { - // Wait 2 times interval so that check time is longer than interval - blockThread(WAIT_FOR_INTERVAL * 2, timerType); - throw new Error('test'); - }); + const blockThread = (timeToBlockThread: number, timerType: TimerType): void => { + jest.useRealTimers(); + const end = Date.now() + timeToBlockThread; - await expect( - async () => - await waitFor(mockErrorFn, { - timeout: WAIT_FOR_TIMEOUT, - interval: WAIT_FOR_INTERVAL, - }), - ).rejects.toThrow(); - - // Verify that the `waitFor` callback has been called the expected number of times - // (timeout / interval + 1), so it confirms that the real duration of callback did not - // cause the real clock timeout when running using fake timers. - expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); - }, -); - -test.each(['fake', 'fake-legacy'] as const)( - 'waits for assertion until timeout is met with %s timers and interval', - async (timerType) => { - setupTimeType(timerType); + while (Date.now() < end) { + // do nothing + } - const mockFn = jest.fn(() => { - throw Error('test'); - }); + setupTimeType(timerType); + }; - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } + const mockErrorFn = jest.fn(() => { + // Wait 2 times interval so that check time is longer than interval + blockThread(WAIT_FOR_INTERVAL * 2, timerType); + throw new Error('test'); + }); - expect(mockFn).toHaveBeenCalledTimes(3); - }, -); + await expect( + async () => + await waitFor(mockErrorFn, { + timeout: WAIT_FOR_TIMEOUT, + interval: WAIT_FOR_INTERVAL, + }), + ).rejects.toThrow(); + + // Verify that the `waitFor` callback has been called the expected number of times + // (timeout / interval + 1), so it confirms that the real duration of callback did not + // cause the real clock timeout when running using fake timers. + expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); + }, + ); -test.each(['fake', 'fake-legacy'] as const)( - 'waits for assertion until timeout is met with %s timers, interval, and onTimeout', - async (timerType) => { - setupTimeType(timerType); + test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers and interval', + async (timerType) => { + setupTimeType(timerType); - const mockErrorFn = jest.fn(() => { - throw Error('test'); - }); + const mockFn = jest.fn(() => { + throw Error('test'); + }); - const mockHandleFn = jest.fn((e) => e); + try { + await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); + } catch { + // suppress + } - try { - await waitFor(() => mockErrorFn(), { - timeout: 400, - interval: 200, - onTimeout: mockHandleFn, + expect(mockFn).toHaveBeenCalledTimes(3); + }, + ); + + test.each(['fake', 'fake-legacy'] as const)( + 'waits for assertion until timeout is met with %s timers, interval, and onTimeout', + async (timerType) => { + setupTimeType(timerType); + + const mockErrorFn = jest.fn(() => { + throw Error('test'); }); - } catch { - // suppress - } - expect(mockErrorFn).toHaveBeenCalledTimes(3); - expect(mockHandleFn).toHaveBeenCalledTimes(1); - }, -); + const mockHandleFn = jest.fn((e) => e); + + try { + await waitFor(() => mockErrorFn(), { + timeout: 400, + interval: 200, + onTimeout: mockHandleFn, + }); + } catch { + // suppress + } + + expect(mockErrorFn).toHaveBeenCalledTimes(3); + expect(mockHandleFn).toHaveBeenCalledTimes(1); + }, + ); +}); From 288f8f945129ff6fd78298ad7a7f3ee0c15e02d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 17:41:36 +0100 Subject: [PATCH 7/9] . --- src/helpers/__tests__/errors.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/helpers/__tests__/errors.test.ts b/src/helpers/__tests__/errors.test.ts index 232527458..e39a0a835 100644 --- a/src/helpers/__tests__/errors.test.ts +++ b/src/helpers/__tests__/errors.test.ts @@ -29,7 +29,7 @@ describe('ErrorWithStack', () => { }); }); -describe('copyStackTrace', () => { +describe('copyStackTraceIfNeeded', () => { test('should copy stack trace from source to target when both are Error instances', () => { const target = new Error('Target error'); const source = new Error('Source error'); @@ -60,8 +60,14 @@ describe('copyStackTrace', () => { const target = new Error('Target error'); const originalStack = target.stack; - const sourceNotError = { message: 'Not an error' }; + copyStackTraceIfNeeded(target, undefined); + expect(target.stack).toBe(originalStack); + + copyStackTraceIfNeeded(target, null as unknown as Error); + expect(target.stack).toBe(originalStack); + + const sourceNotError = { message: 'Not an error' }; copyStackTraceIfNeeded(target, sourceNotError as Error); expect(target.stack).toBe(originalStack); From 6562ae2e0bdd03782fd16063df4a26d938f7d378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 11 Jan 2026 17:43:20 +0100 Subject: [PATCH 8/9] . --- src/__tests__/wait-for-old.test.tsx | 349 ---------------------------- 1 file changed, 349 deletions(-) delete mode 100644 src/__tests__/wait-for-old.test.tsx diff --git a/src/__tests__/wait-for-old.test.tsx b/src/__tests__/wait-for-old.test.tsx deleted file mode 100644 index 88c8429d3..000000000 --- a/src/__tests__/wait-for-old.test.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import * as React from 'react'; -import { Pressable, Text, TouchableOpacity, View } from 'react-native'; - -import { configure, fireEvent, render, screen, waitFor } from '..'; - -class Banana extends React.Component { - changeFresh = () => { - this.props.onChangeFresh(); - }; - - render() { - return ( - - {this.props.fresh && Fresh} - - Change freshness! - - - ); - } -} - -class BananaContainer extends React.Component { - state = { fresh: false }; - - onChangeFresh = async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - this.setState({ fresh: true }); - }; - - render() { - return ; - } -} - -afterEach(() => { - jest.useRealTimers(); -}); - -test('waits for element until it stops throwing', async () => { - await render(); - - await fireEvent.press(screen.getByText('Change freshness!')); - - expect(screen.queryByText('Fresh')).toBeNull(); - - const freshBananaText = await waitFor(() => screen.getByText('Fresh')); - - expect(freshBananaText.props.children).toBe('Fresh'); -}); - -test('waits for element until timeout is met', async () => { - await render(); - - await fireEvent.press(screen.getByText('Change freshness!')); - - await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); - - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh')); -}); - -test('waitFor defaults to asyncWaitTimeout config option', async () => { - configure({ asyncUtilTimeout: 100 }); - await render(); - - await fireEvent.press(screen.getByText('Change freshness!')); - await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); - - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh'), { timeout: 1000 }); -}); - -test('waitFor timeout option takes precendence over `asyncWaitTimeout` config option', async () => { - configure({ asyncUtilTimeout: 2000 }); - await render(); - - await fireEvent.press(screen.getByText('Change freshness!')); - await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); - - // Async action ends after 300ms and we only waited 100ms, so we need to wait - // for the remaining async actions to finish - await waitFor(() => screen.getByText('Fresh')); -}); - -test('waits for element with custom interval', async () => { - const mockFn = jest.fn(() => { - throw Error('test'); - }); - - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } - - expect(mockFn).toHaveBeenCalledTimes(2); -}); - -// this component is convoluted on purpose. It is not a good react pattern, but it is valid -// react code that will run differently between different react versions (17 and 18), so we need -// explicit tests for it -const Comp = ({ onPress }: { onPress: () => void }) => { - const [state, setState] = React.useState(false); - - React.useEffect(() => { - if (state) { - onPress(); - } - }, [state, onPress]); - - return ( - { - await Promise.resolve(); - setState(true); - }} - > - Trigger - - ); -}; - -test('waits for async event with fireEvent', async () => { - const spy = jest.fn(); - await render(); - - await fireEvent.press(screen.getByText('Trigger')); - - await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); -}); - -test.each([false, true])( - 'waits for element until it stops throwing using fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - await render(); - - await fireEvent.press(screen.getByText('Change freshness!')); - expect(screen.queryByText('Fresh')).toBeNull(); - - jest.advanceTimersByTime(300); - const freshBananaText = await waitFor(() => screen.getByText('Fresh')); - - expect(freshBananaText.props.children).toBe('Fresh'); - }, -); - -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - - const mockFn = jest.fn(() => { - throw Error('test'); - }); - - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } - - expect(mockFn).toHaveBeenCalledTimes(3); - }, -); - -test.each([false, true])( - 'waits for assertion until timeout is met with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - - const mockErrorFn = jest.fn(() => { - throw Error('test'); - }); - - const mockHandleFn = jest.fn((e) => e); - - try { - await waitFor(() => mockErrorFn(), { - timeout: 400, - interval: 200, - onTimeout: mockHandleFn, - }); - } catch { - // suppress - } - - expect(mockErrorFn).toHaveBeenCalledTimes(3); - expect(mockHandleFn).toHaveBeenCalledTimes(1); - }, -); - -const blockThread = (timeToBlockThread: number, legacyFakeTimers: boolean) => { - jest.useRealTimers(); - const end = Date.now() + timeToBlockThread; - - while (Date.now() < end) { - // do nothing - } - - jest.useFakeTimers({ legacyFakeTimers }); -}; - -test.each([true, false])( - 'it should not depend on real time when using fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - const WAIT_FOR_INTERVAL = 20; - const WAIT_FOR_TIMEOUT = WAIT_FOR_INTERVAL * 5; - - const mockErrorFn = jest.fn(() => { - // Wait 2 times interval so that check time is longer than interval - blockThread(WAIT_FOR_INTERVAL * 2, legacyFakeTimers); - throw new Error('test'); - }); - - await expect( - async () => - await waitFor(mockErrorFn, { - timeout: WAIT_FOR_TIMEOUT, - interval: WAIT_FOR_INTERVAL, - }), - ).rejects.toThrow(); - - // Verify that the `waitFor` callback has been called the expected number of times - // (timeout / interval + 1), so it confirms that the real duration of callback did not - // cause the real clock timeout when running using fake timers. - expect(mockErrorFn).toHaveBeenCalledTimes(WAIT_FOR_TIMEOUT / WAIT_FOR_INTERVAL + 1); - }, -); - -test.each([false, true])( - 'awaiting something that succeeds before timeout works with fake timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - - let calls = 0; - const mockFn = jest.fn(() => { - calls += 1; - if (calls < 3) { - throw Error('test'); - } - }); - - try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); - } catch { - // suppress - } - - expect(mockFn).toHaveBeenCalledTimes(3); - }, -); - -test.each([ - [false, false], - [true, false], - [true, true], -])( - 'flushes scheduled updates before returning (fakeTimers = %s, legacyFakeTimers = %s)', - async (fakeTimers, legacyFakeTimers) => { - if (fakeTimers) { - jest.useFakeTimers({ legacyFakeTimers }); - } - - function Apple({ onPress }: { onPress: (color: string) => void }) { - const [color, setColor] = React.useState('green'); - const [syncedColor, setSyncedColor] = React.useState(color); - - // On mount, set the color to "red" in a promise microtask - React.useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then - Promise.resolve('red').then((c) => setColor(c)); - }, []); - - // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect - React.useEffect(() => { - setSyncedColor(color); - }, [color]); - - return ( - - {color} - onPress(syncedColor)}> - Trigger - - - ); - } - - const onPress = jest.fn(); - await render(); - - // Required: this `waitFor` will succeed on first check, because the "root" view is there - // since the initial mount. - await waitFor(() => screen.getByTestId('root')); - - // This `waitFor` will also succeed on first check, because the promise that sets the - // `color` state to "red" resolves right after the previous `await waitFor` statement. - await waitFor(() => screen.getByText('red')); - - // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledWith('red'); - }, -); - -test('waitFor throws if expectation is not a function', async () => { - await expect( - // @ts-expect-error intentionally passing non-function - waitFor('not a function'), - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Received \`expectation\` arg must be a function"`); -}); - -test.each([false, true])( - 'waitFor throws clear error when switching from fake timers to real timers (legacyFakeTimers = %s)', - async (legacyFakeTimers) => { - jest.useFakeTimers({ legacyFakeTimers }); - - const waitForPromise = waitFor(() => { - // Switch to real timers during waitFor - this should trigger an error - jest.useRealTimers(); - throw new Error('test'); - }); - - await expect(waitForPromise).rejects.toThrow( - 'Changed from using fake timers to real timers while using waitFor', - ); - }, -); - -test('waitFor throws clear error when switching from real timers to fake timers', async () => { - jest.useRealTimers(); - - const waitForPromise = waitFor(() => { - // Switch to fake timers during waitFor - this should trigger an error - jest.useFakeTimers(); - throw new Error('test'); - }); - - await expect(waitForPromise).rejects.toThrow( - 'Changed from using real timers to fake timers while using waitFor', - ); -}); From c75c0891c96a6d1a11e459666ba482a35a189758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 13 Jan 2026 13:20:10 +0100 Subject: [PATCH 9/9] . --- src/__tests__/wait-for.test.tsx | 87 ++++++++++++--------------------- 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 03caed81c..d3e9c70bb 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -49,20 +49,13 @@ describe('successful waiting', () => { test('waits for async event with fireEvent', async () => { const Component = ({ onDelayedPress }: { onDelayedPress: () => void }) => { - const [state, setState] = React.useState(false); - - React.useEffect(() => { - if (state) { - onDelayedPress(); - } - }, [state, onDelayedPress]); - return ( { - await Promise.resolve(); - setState(true); - }} + onPress={() => + setTimeout(() => { + onDelayedPress(); + }, 100) + } > Press @@ -87,17 +80,16 @@ describe('successful waiting', () => { const [color, setColor] = React.useState('green'); const [syncedColor, setSyncedColor] = React.useState(color); - // On mount, set the color to "red" in a promise microtask + // Cascading state updates + React.useEffect(() => { + setSyncedColor(color); + }, [color]); + React.useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/prefer-await-to-then Promise.resolve('red').then((c) => setColor(c)); }, []); - // Sync the `color` state to `syncedColor` state, but with a delay caused by the effect - React.useEffect(() => { - setSyncedColor(color); - }, [color]); - return ( {color} @@ -110,13 +102,6 @@ describe('successful waiting', () => { const onPress = jest.fn(); await render(); - - // Required: this `waitFor` will succeed on first check, because the "root" view is there - // since the initial mount. - await waitFor(() => screen.getByTestId('root')); - - // This `waitFor` will also succeed on first check, because the promise that sets the - // `color` state to "red" resolves right after the previous `await waitFor` statement. await waitFor(() => screen.getByText('red')); // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. @@ -145,28 +130,20 @@ describe('successful waiting', () => { }); describe('timeout errors', () => { - test('throws timeout error when condition never becomes true', async () => { - function Component() { - return Hello; - } - - await render(); - await expect( - waitFor(() => screen.getByText('Never appears'), { timeout: 100 }), - ).rejects.toThrow('Unable to find an element with text: Never appears'); - }); - - test('throws timeout error with fake timers when condition never becomes true', async () => { - jest.useFakeTimers(); - await render(); - - const waitForPromise = waitFor(() => screen.getByText('Never appears'), { timeout: 100 }); + test.each(['real', 'fake', 'fake-legacy'] as const)( + 'throws timeout error when condition never succeeds with %s timers', + async (timerType) => { + setupTimeType(timerType); + function Component() { + return Hello; + } - await jest.advanceTimersByTimeAsync(100); - await expect(waitForPromise).rejects.toThrow( - 'Unable to find an element with text: Never appears', - ); - }); + await render(); + await expect( + waitFor(() => screen.getByText('Never appears'), { timeout: 100 }), + ).rejects.toThrow('Unable to find an element with text: Never appears'); + }, + ); test('throws generic timeout error when promise rejects with falsy value until timeout', async () => { await expect( @@ -379,17 +356,17 @@ describe('fake timers behavior', () => { async (timerType) => { setupTimeType(timerType); - const mockFn = jest.fn(() => { + const expection = jest.fn(() => { throw Error('test'); }); try { - await waitFor(() => mockFn(), { timeout: 400, interval: 200 }); + await waitFor(() => expection(), { timeout: 400, interval: 200 }); } catch { // suppress } - expect(mockFn).toHaveBeenCalledTimes(3); + expect(expection).toHaveBeenCalledTimes(3); }, ); @@ -398,24 +375,24 @@ describe('fake timers behavior', () => { async (timerType) => { setupTimeType(timerType); - const mockErrorFn = jest.fn(() => { + const expection = jest.fn(() => { throw Error('test'); }); - const mockHandleFn = jest.fn((e) => e); + const onTimeout = jest.fn((e) => e); try { - await waitFor(() => mockErrorFn(), { + await waitFor(() => expection(), { timeout: 400, interval: 200, - onTimeout: mockHandleFn, + onTimeout: onTimeout, }); } catch { // suppress } - expect(mockErrorFn).toHaveBeenCalledTimes(3); - expect(mockHandleFn).toHaveBeenCalledTimes(1); + expect(expection).toHaveBeenCalledTimes(3); + expect(onTimeout).toHaveBeenCalledTimes(1); }, ); });