From 36c64a1eb3718c5739c58c39e0c0eda09d3186fb Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Thu, 9 Sep 2021 20:28:22 +1000 Subject: [PATCH] test(fake-timers): add more tests to test suite for fake timers fix the code after code review and clean up fix the test timeout is false clean up --- src/__tests__/asyncHook.fakeTimers.test.ts | 264 ++++++++++++++++++++- src/core/asyncUtils.ts | 22 +- src/helpers/createTimeoutController.ts | 39 ++- 3 files changed, 300 insertions(+), 25 deletions(-) diff --git a/src/__tests__/asyncHook.fakeTimers.test.ts b/src/__tests__/asyncHook.fakeTimers.test.ts index 69bff39d..9c1c4559 100644 --- a/src/__tests__/asyncHook.fakeTimers.test.ts +++ b/src/__tests__/asyncHook.fakeTimers.test.ts @@ -1,4 +1,27 @@ +import { useState, useRef, useEffect } from 'react' + describe('async hook (fake timers) tests', () => { + const useSequence = (values: string[], intervalMs = 50) => { + const [first, ...otherValues] = values + const [value, setValue] = useState(() => first) + const index = useRef(0) + + useEffect(() => { + const interval = setInterval(() => { + setValue(otherValues[index.current++]) + if (index.current >= otherValues.length) { + clearInterval(interval) + } + }, intervalMs) + return () => { + clearInterval(interval) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, otherValues) + + return value + } + beforeEach(() => { jest.useFakeTimers() }) @@ -70,7 +93,7 @@ describe('async hook (fake timers) tests', () => { setTimeout(() => { actual = expected - }, 101) + }, 30) let complete = false @@ -80,14 +103,243 @@ describe('async hook (fake timers) tests', () => { expect(actual).toBe(expected) complete = true }, - { timeout: 100, interval: 50 } + { timeout: 29, interval: 10 } ) - ).rejects.toThrow(Error('Timed out in waitFor after 100ms.')) + ).rejects.toThrow(Error('Timed out in waitFor after 29ms.')) expect(complete).toBe(false) }) + + test('should wait for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + }) + + test('should wait for multiple updates', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second', 'third']) + ) + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + + await waitForNextUpdate() + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) + + expect(result.current).toBe('first') + + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) + }) + + test('should not reject when waiting for next update if timeout has been disabled', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'], 1100)) + + expect(result.current).toBe('first') + + await waitForNextUpdate({ timeout: false }) + + expect(result.current).toBe('second') + }) + + test('should wait for expectation to pass', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + expect(result.current).toBe('first') + + let complete = false + await waitFor(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should wait for arbitrary expectation to pass', async () => { + const { waitFor } = renderHook(() => null) + + let actual = 0 + const expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + let complete = false + await waitFor(() => { + expect(actual).toBe(expected) + complete = true + }) + + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second'])) + + expect(result.current).toBe('first') + + let complete = false + await waitFor(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should wait for truthy value', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + expect(result.current).toBe('first') + + await waitFor(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should wait for arbitrary truthy value', async () => { + const { waitFor } = renderHook(() => null) + + let actual = 0 + const expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitFor(() => actual === 1) + + expect(actual).toBe(expected) + }) + + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + expect(result.current).toBe('first') + + await expect( + waitFor( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) + }) + + test('should not reject when waiting for expectation to pass if timeout has been disabled', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'], 550)) + + expect(result.current).toBe('first') + + await waitFor( + () => { + expect(result.current).toBe('third') + }, + { timeout: false } + ) + + expect(result.current).toBe('third') + }) + + test('should check on interval when waiting for expectation to pass', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + let checks = 0 + + await waitFor( + () => { + checks++ + return result.current === 'third' + }, + { interval: 100 } + ) + + expect(checks).toBe(3) + }) + + test('should wait for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence(['first', 'second', 'third']) + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should wait for arbitrary value to change', async () => { + const { waitForValueToChange } = renderHook(() => null) + + let actual = 0 + const expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitForValueToChange(() => actual) + + expect(actual).toBe(expected) + }) + + test('should reject if timeout exceeded when waiting for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence(['first', 'second', 'third']) + ) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => result.current === 'third', { + timeout: 75 + }) + ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) + }) + + test('should not reject when waiting for value to change if timeout is disabled', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence(['first', 'second', 'third'], 550) + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third', { + timeout: false + }) + + expect(result.current).toBe('third') + }) + + test('should reject if selector throws error', async () => { + const { result, waitForValueToChange } = renderHook(() => useSequence(['first', 'second'])) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current + }) + ).rejects.toThrow(Error('Something Unexpected')) + }) }) }) - -// eslint-disable-next-line jest/no-export -export {} diff --git a/src/core/asyncUtils.ts b/src/core/asyncUtils.ts index a62e5940..07c795fa 100644 --- a/src/core/asyncUtils.ts +++ b/src/core/asyncUtils.ts @@ -7,39 +7,41 @@ import { AsyncUtils } from '../types' -import { createTimeoutController } from '../helpers/createTimeoutController' +import { createTimeoutController, DEFAULT_TIMEOUT } from '../helpers/createTimeoutController' import { TimeoutError } from '../helpers/error' -const DEFAULT_TIMEOUT = 1000 const DEFAULT_INTERVAL = 50 function asyncUtils(act: Act, addResolver: (callback: () => void) => void): AsyncUtils { - const wait = async (callback: () => boolean | void, { interval, timeout }: WaitOptions) => { + const wait = async ( + callback: () => boolean | void, + { interval, timeout }: Required + ) => { const checkResult = () => { const callbackResult = callback() return callbackResult ?? callbackResult === undefined } - const timeoutSignal = createTimeoutController(timeout as number | boolean, false) + const timeoutController = createTimeoutController(timeout, { allowFakeTimers: !interval }) const waitForResult = async () => { while (true) { - const intervalSignal = createTimeoutController(interval as number | boolean, true) - timeoutSignal.onTimeout(() => intervalSignal.cancel()) + const intervalController = createTimeoutController(interval, { allowFakeTimers: true }) + timeoutController.onTimeout(() => intervalController.cancel()) - await intervalSignal.wrap(new Promise(addResolver)) + await intervalController.wrap(new Promise(addResolver)) - if (checkResult() || timeoutSignal.timedOut) { + if (checkResult() || timeoutController.timedOut) { return } } } if (!checkResult()) { - await act(() => timeoutSignal.wrap(waitForResult())) + await act(() => timeoutController.wrap(waitForResult())) } - return !timeoutSignal.timedOut + return !timeoutController.timedOut } const waitFor = async ( diff --git a/src/helpers/createTimeoutController.ts b/src/helpers/createTimeoutController.ts index 033d052f..6c02fe76 100644 --- a/src/helpers/createTimeoutController.ts +++ b/src/helpers/createTimeoutController.ts @@ -1,8 +1,27 @@ import { jestFakeTimersAreEnabled } from './jestFakeTimersAreEnabled' -function createTimeoutController(timeout: number | boolean, allowFakeTimers: boolean) { +const DEFAULT_TIMEOUT = 1000 + +function createTimeoutController(timeout: number | false, options: { allowFakeTimers: boolean }) { let timeoutId: NodeJS.Timeout const timeoutCallbacks: Array<() => void> = [] + let finished = false + + const { allowFakeTimers = false } = options + + const advanceTime = async (currentMs: number) => { + // eslint-disable-next-line no-negated-condition + if (currentMs < (!timeout ? DEFAULT_TIMEOUT : timeout)) { + jest.advanceTimersByTime(1) + + await Promise.resolve() + + if (finished) { + return + } + await advanceTime(currentMs + 1) + } + } const timeoutController = { onTimeout(callback: () => void) { @@ -12,26 +31,28 @@ function createTimeoutController(timeout: number | boolean, allowFakeTimers: boo return new Promise((resolve, reject) => { timeoutController.timedOut = false timeoutController.onTimeout(resolve) - if (timeout) { timeoutId = setTimeout(() => { timeoutController.timedOut = true timeoutCallbacks.forEach((callback) => callback()) resolve() - }, timeout as number) - - if (jestFakeTimersAreEnabled() && allowFakeTimers) { - jest.advanceTimersByTime(timeout as number) - } + }, timeout) + } + if (jestFakeTimersAreEnabled() && allowFakeTimers) { + advanceTime(0) } promise .then(resolve) .catch(reject) - .finally(() => timeoutController.cancel()) + .finally(() => { + finished = true + timeoutController.cancel() + }) }) }, cancel() { + finished = true clearTimeout(timeoutId) }, timedOut: false @@ -40,4 +61,4 @@ function createTimeoutController(timeout: number | boolean, allowFakeTimers: boo return timeoutController } -export { createTimeoutController } +export { createTimeoutController, DEFAULT_TIMEOUT }