From 53e4b417acd1b19338b28a4c6a6845f4016a16b6 Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Thu, 16 Jul 2020 22:53:33 +1000 Subject: [PATCH 1/4] Add interval to async utilities to supplement post render checks Resolves #241 Resolves #393 --- docs/api-reference.md | 75 ++++++++++++---- src/asyncUtils.js | 34 +++++++- test/asyncHook.test.js | 188 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 262 insertions(+), 35 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index ab91f42f..b14dc806 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -152,50 +152,90 @@ variable to `true` before importing `@testing-library/react-hooks` will also dis ### `waitForNextUpdate` ```js -function waitForNextUpdate(options?: WaitOptions): Promise +function waitForNextUpdate(options?: { + timeout?: number +}): Promise ``` Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as the result of an asynchronous update. -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +#### `timeout` -### `wait` +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +### `waitFor` ```js -function wait(callback: function(): boolean|void, options?: WaitOptions): Promise +function waitFor(callback: function(): boolean|void, options?: { + interval?: number, + timeout?: number, + suppressErrors?: boolean +}): Promise ``` Returns a `Promise` that resolves if the provided callback executes without exception and returns a truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result) in the callback to perform assertion or to test values. -The callback is tested after each render of the hook. By default, errors raised from the callback -will be suppressed (`suppressErrors = true`). +#### `interval` -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. +By default, an interval of 50ms is used. + +#### `timeout` + +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +#### `suppressErrors` + +If this option is set to `true`, any errors that occur while waiting are treated as a failed check. +If this option is set to `false`, any errors that occur while waiting cause the promise to be +rejected. By default, errors are suppressed for this utility. ### `waitForValueToChange` ```js -function waitForValueToChange(selector: function(): any, options?: WaitOptions): Promise +function waitForValueToChange(selector: function(): any, options?: { + interval?: number, + timeout?: number, + suppressErrors?: boolean +}): Promise ``` Returns a `Promise` that resolves if the value returned from the provided selector changes. It expected that the [`result` of `renderHook`](/reference/api#result) to select the value for comparison. -The value is selected for comparison after each render of the hook. By default, errors raised from -selecting the value will not be suppressed (`suppressErrors = false`). +#### `interval` + +The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. +By default, an interval of 50ms is used. + +#### `timeout` + +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +#### `suppressErrors` -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +If this option is set to `true`, any errors that occur while waiting are treated as a failed check. +If this option is set to `false`, any errors that occur while waiting cause the promise to be +rejected. By default, errors are not suppressed for this utility. -### `wait` Options +### `wait` -The async utilities accept the following options: +_(DEPRECATED, use [`waitFor`](/reference/api#waitFor) instead)_ + +```js +function waitFor(callback: function(): boolean|void, options?: { + timeout?: number, + suppressErrors?: boolean +}): Promise +``` + +Returns a `Promise` that resolves if the provided callback executes without exception and returns a +truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result) +in the callback to perform assertion or to test values. #### `timeout` @@ -205,5 +245,4 @@ The maximum amount of time in milliseconds (ms) to wait. By default, no timeout If this option is set to `true`, any errors that occur while waiting are treated as a failed check. If this option is set to `false`, any errors that occur while waiting cause the promise to be -rejected. Please refer to the [utility descriptions](/reference/api#async-utilities) for the default -values of this option (if applicable). +rejected. By default, errors are suppressed for this utility. diff --git a/src/asyncUtils.js b/src/asyncUtils.js index c3cf7ab9..a53906c5 100644 --- a/src/asyncUtils.js +++ b/src/asyncUtils.js @@ -6,6 +6,14 @@ function createTimeoutError(utilName, { timeout }) { return timeoutError } +function resolveAfter(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +let hasWarnedDeprecatedWait = false + function asyncUtils(addResolver) { let nextUpdatePromise = null @@ -30,7 +38,7 @@ function asyncUtils(addResolver) { await nextUpdatePromise } - const wait = async (callback, { timeout, suppressErrors = true } = {}) => { + const waitFor = async (callback, { interval = 50, timeout, suppressErrors = true } = {}) => { const checkResult = () => { try { const callbackResult = callback() @@ -47,13 +55,13 @@ function asyncUtils(addResolver) { while (true) { const startTime = Date.now() try { - await waitForNextUpdate({ timeout }) + await Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) if (checkResult()) { return } } catch (e) { if (e.timeout) { - throw createTimeoutError('wait', { timeout: initialTimeout }) + throw createTimeoutError('waitFor', { timeout: initialTimeout }) } throw e } @@ -69,7 +77,7 @@ function asyncUtils(addResolver) { const waitForValueToChange = async (selector, options = {}) => { const initialValue = selector() try { - await wait(() => selector() !== initialValue, { + await waitFor(() => selector() !== initialValue, { suppressErrors: false, ...options }) @@ -81,8 +89,26 @@ function asyncUtils(addResolver) { } } + const wait = async (callback, { timeout, suppressErrors } = {}) => { + if (!hasWarnedDeprecatedWait) { + hasWarnedDeprecatedWait = true + console.warn( + '`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitFor.' + ) + } + try { + await waitFor(callback, { timeout, suppressErrors }) + } catch (e) { + if (e.timeout) { + throw createTimeoutError('wait', { timeout }) + } + throw e + } + } + return { wait, + waitFor, waitForNextUpdate, waitForValueToChange } diff --git a/test/asyncHook.test.js b/test/asyncHook.test.js index 61a58cb6..df0440f5 100644 --- a/test/asyncHook.test.js +++ b/test/asyncHook.test.js @@ -67,25 +67,47 @@ describe('async hook tests', () => { }) test('should wait for expectation to pass', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') let complete = false - await wait(() => { + 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 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + let complete = false + await waitFor( + () => { + expect(actual).toBe(expected) + complete = true + }, + { interval: 100 } + ) + + expect(complete).toBe(true) + }) + test('should not hang if expectation is already passing', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second')) expect(result.current).toBe('first') let complete = false - await wait(() => { + await waitFor(() => { expect(result.current).toBe('first') complete = true }) @@ -93,12 +115,12 @@ describe('async hook tests', () => { }) test('should reject if callback throws error', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { if (result.current === 'second') { throw new Error('Something Unexpected') @@ -113,12 +135,12 @@ describe('async hook tests', () => { }) test('should reject if callback immediately throws error', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { throw new Error('Something Unexpected') }, @@ -130,28 +152,43 @@ describe('async hook tests', () => { }) test('should wait for truthy value', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') - await wait(() => result.current === 'third') + 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 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitFor(() => actual === 1, { interval: 100 }) + + expect(actual).toBe(expected) + }) + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { expect(result.current).toBe('third') }, { timeout: 75 } ) - ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) }) test('should wait for value to change', async () => { @@ -166,6 +203,21 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) + test('should wait for arbitrary value to change', async () => { + const { waitForValueToChange } = renderHook(() => null) + + let actual = 0 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitForValueToChange(() => actual, { interval: 100 }) + + 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') @@ -214,4 +266,114 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) + + test('should wait for expectation to pass (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should wait for arbitrary expectation to pass (deprecated)', async () => { + const { wait } = renderHook(() => null) + + let actual = 0 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + let complete = false + await wait( + () => { + expect(actual).toBe(expected) + complete = true + }, + { interval: 100 } + ) + + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should reject if callback throws error (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should reject if callback immediately throws error (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + throw new Error('Something Unexpected') + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should wait for truthy value (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await wait(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for expectation to pass (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + }) }) From 01d8e509ce033dbeb7fad5f36f31f46ff7c5081c Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Thu, 16 Jul 2020 23:11:12 +1000 Subject: [PATCH 2/4] Fixed doc links for waitFor --- docs/api-reference.md | 2 +- src/asyncUtils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b14dc806..b7a4ad40 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -224,7 +224,7 @@ rejected. By default, errors are not suppressed for this utility. ### `wait` -_(DEPRECATED, use [`waitFor`](/reference/api#waitFor) instead)_ +_(DEPRECATED, use [`waitFor`](/reference/api#waitfor) instead)_ ```js function waitFor(callback: function(): boolean|void, options?: { diff --git a/src/asyncUtils.js b/src/asyncUtils.js index a53906c5..35655d79 100644 --- a/src/asyncUtils.js +++ b/src/asyncUtils.js @@ -93,7 +93,7 @@ function asyncUtils(addResolver) { if (!hasWarnedDeprecatedWait) { hasWarnedDeprecatedWait = true console.warn( - '`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitFor.' + '`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitfor.' ) } try { From 42cba7eb9d5daf4945e5d79f50fd2a7424a70b21 Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Fri, 17 Jul 2020 08:43:47 +1000 Subject: [PATCH 3/4] Update example for deprecated wait utility --- docs/api-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b7a4ad40..ae978717 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -227,7 +227,7 @@ rejected. By default, errors are not suppressed for this utility. _(DEPRECATED, use [`waitFor`](/reference/api#waitfor) instead)_ ```js -function waitFor(callback: function(): boolean|void, options?: { +function wait(callback: function(): boolean|void, options?: { timeout?: number, suppressErrors?: boolean }): Promise From 1ddafa25a6134327b4b8db2d330dce7cf5f46a08 Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Fri, 17 Jul 2020 08:53:25 +1000 Subject: [PATCH 4/4] Disable interval checking by default --- docs/api-reference.md | 6 ++++-- src/asyncUtils.js | 9 +++++++-- test/asyncHook.test.js | 22 ---------------------- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index ae978717..53c7623a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -181,7 +181,8 @@ in the callback to perform assertion or to test values. #### `interval` The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. -By default, an interval of 50ms is used. +Interval checking is disabled if `interval` is not provided in the options or provided as a `falsy` +value. By default, it is disabled. #### `timeout` @@ -210,7 +211,8 @@ comparison. #### `interval` The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. -By default, an interval of 50ms is used. +Interval checking is disabled if `interval` is not provided in the options or provided as a `falsy` +value. By default, it is disabled. #### `timeout` diff --git a/src/asyncUtils.js b/src/asyncUtils.js index 35655d79..260d13f0 100644 --- a/src/asyncUtils.js +++ b/src/asyncUtils.js @@ -38,7 +38,7 @@ function asyncUtils(addResolver) { await nextUpdatePromise } - const waitFor = async (callback, { interval = 50, timeout, suppressErrors = true } = {}) => { + const waitFor = async (callback, { interval, timeout, suppressErrors = true } = {}) => { const checkResult = () => { try { const callbackResult = callback() @@ -55,7 +55,12 @@ function asyncUtils(addResolver) { while (true) { const startTime = Date.now() try { - await Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) + const nextCheck = interval + ? Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) + : waitForNextUpdate({ timeout }) + + await nextCheck + if (checkResult()) { return } diff --git a/test/asyncHook.test.js b/test/asyncHook.test.js index df0440f5..2ba12b4b 100644 --- a/test/asyncHook.test.js +++ b/test/asyncHook.test.js @@ -280,28 +280,6 @@ describe('async hook tests', () => { expect(complete).toBe(true) }) - test('should wait for arbitrary expectation to pass (deprecated)', async () => { - const { wait } = renderHook(() => null) - - let actual = 0 - let expected = 1 - - setTimeout(() => { - actual = expected - }, 200) - - let complete = false - await wait( - () => { - expect(actual).toBe(expected) - complete = true - }, - { interval: 100 } - ) - - expect(complete).toBe(true) - }) - test('should not hang if expectation is already passing (deprecated)', async () => { const { result, wait } = renderHook(() => useSequence('first', 'second'))