diff --git a/disable-error-filtering.js b/disable-error-filtering.js new file mode 100644 index 00000000..25e71c79 --- /dev/null +++ b/disable-error-filtering.js @@ -0,0 +1 @@ +process.env.RHTL_DISABLE_ERROR_FILTERING = true diff --git a/docs/api-reference.md b/docs/api-reference.md index 22ab6df4..c5bbee07 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -12,6 +12,7 @@ route: '/reference/api' - [`cleanup`](/reference/api#cleanup) - [`addCleanup`](/reference/api#addcleanup) - [`removeCleanup`](/reference/api#removecleanup) +- [`console.error`](/reference/api#consoleerror) --- @@ -154,7 +155,7 @@ module.exports = { ``` Alternatively, you can change your test to import from `@testing-library/react-hooks/pure` instead -of the regular imports. This applys to any of our export methods documented in +of the regular imports. This applies to any of our export methods documented in [Rendering](/installation#being-specific). ```diff @@ -270,3 +271,49 @@ Interval checking is disabled if `interval` is not provided as a `falsy`. _Default: 1000_ The maximum amount of time in milliseconds (ms) to wait. + +--- + +## `console.error` + +In order to catch errors that are produced in all parts of the hook's lifecycle, the test harness +used to wrap the hook call includes an +[Error Boundary](https://reactjs.org/docs/error-boundaries.html) which causes a +[significant amount of output noise](https://reactjs.org/docs/error-boundaries.html#component-stack-traces) +in tests. + +To keep test output clean, we patch `console.error` when `renderHook` is called to filter out the +unnecessary logging and restore the original version during cleanup. This side-effect can affect +tests that also patch `console.error` (e.g. to assert a specific error message get logged) by +replacing their custom implementation as well. + +### Disabling `console.error` filtering + +Importing `@testing-library/react-hooks/disable-error-filtering.js` in test setup files disable the +error filtering feature and not patch `console.error` in any way. + +For example, in [Jest](https://jestjs.io/) this can be added to your +[Jest config](https://jestjs.io/docs/configuration): + +```js +module.exports = { + setupFilesAfterEnv: [ + '@testing-library/react-hooks/disable-error-filtering.js' + // other setup files + ] +} +``` + +Alternatively, you can change your test to import from `@testing-library/react-hooks/pure` instead +of the regular imports. This applies to any of our export methods documented in +[Rendering](/installation#being-specific). + +```diff +- import { renderHook, cleanup, act } from '@testing-library/react-hooks' ++ import { renderHook, cleanup, act } from '@testing-library/react-hooks/pure' +``` + +If neither of these approaches are suitable, setting the `RHTL_DISABLE_ERROR_FILTERING` environment +variable to `true` before importing `@testing-library/react-hooks` will also disable this feature. + +> Please note that this may result is a significant amount of additional logging in you test output. diff --git a/package.json b/package.json index fa49afbf..053d6602 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "native", "server", "pure", + "disable-error-filtering.js", "dont-cleanup-after-each.js" ], "author": "Michael Peyper ", diff --git a/src/dom/__tests__/errorHook.test.ts b/src/dom/__tests__/errorHook.test.ts index c7f21847..69e54270 100644 --- a/src/dom/__tests__/errorHook.test.ts +++ b/src/dom/__tests__/errorHook.test.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { renderHook } from '..' +import { renderHook, act } from '..' describe('error hook tests', () => { function useError(throwError?: boolean) { @@ -142,4 +142,51 @@ describe('error hook tests', () => { expect(result.error).toBe(undefined) }) }) + + describe('error output suppression', () => { + test('should allow console.error to be mocked', async () => { + const consoleError = console.error + console.error = jest.fn() + + try { + const { rerender, unmount } = renderHook( + (stage) => { + useEffect(() => { + console.error(`expected in effect`) + return () => { + console.error(`expected in unmount`) + } + }, []) + console.error(`expected in ${stage}`) + }, + { + initialProps: 'render' + } + ) + + act(() => { + console.error('expected in act') + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + console.error('expected in async act') + }) + + rerender('rerender') + + unmount() + + expect(console.error).toBeCalledWith('expected in render') + expect(console.error).toBeCalledWith('expected in effect') + expect(console.error).toBeCalledWith('expected in act') + expect(console.error).toBeCalledWith('expected in async act') + expect(console.error).toBeCalledWith('expected in rerender') + expect(console.error).toBeCalledWith('expected in unmount') + expect(console.error).toBeCalledTimes(6) + } finally { + console.error = consoleError + } + }) + }) }) diff --git a/src/dom/__tests__/errorSuppression.disabled.test.ts b/src/dom/__tests__/errorSuppression.disabled.test.ts new file mode 100644 index 00000000..472204c9 --- /dev/null +++ b/src/dom/__tests__/errorSuppression.disabled.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '..' + +describe('error output suppression (disabled) tests', () => { + function useError(throwError?: boolean) { + if (throwError) { + throw new Error('expected') + } + return true + } + + const originalConsoleError = console.error + const mockConsoleError = jest.fn() + + beforeAll(() => { + process.env.RHTL_DISABLE_ERROR_FILTERING = 'true' + }) + + beforeEach(() => { + console.error = mockConsoleError + }) + + afterEach(() => { + console.error = originalConsoleError + }) + + test('should not suppress error output', () => { + const { result } = renderHook(() => useError(true)) + + expect(result.error).toEqual(Error('expected')) + expect(mockConsoleError).toBeCalledWith( + expect.stringMatching(/^Error: Uncaught \[Error: expected\]/), + expect.any(Error) + ) + expect(mockConsoleError).toBeCalledWith( + expect.stringMatching(/^The above error occurred in the component:/) + ) + expect(mockConsoleError).toBeCalledTimes(2) + }) +}) diff --git a/src/dom/__tests__/errorSuppression.pure.test.ts b/src/dom/__tests__/errorSuppression.pure.test.ts new file mode 100644 index 00000000..56ac58f2 --- /dev/null +++ b/src/dom/__tests__/errorSuppression.pure.test.ts @@ -0,0 +1,35 @@ +import { renderHook } from '../pure' + +describe('error output suppression (pure) tests', () => { + function useError(throwError?: boolean) { + if (throwError) { + throw new Error('expected') + } + return true + } + + const originalConsoleError = console.error + const mockConsoleError = jest.fn() + + beforeEach(() => { + console.error = mockConsoleError + }) + + afterEach(() => { + console.error = originalConsoleError + }) + + test('should not suppress error output', () => { + const { result } = renderHook(() => useError(true)) + + expect(result.error).toEqual(Error('expected')) + expect(mockConsoleError).toBeCalledWith( + expect.stringMatching(/^Error: Uncaught \[Error: expected\]/), + expect.any(Error) + ) + expect(mockConsoleError).toBeCalledWith( + expect.stringMatching(/^The above error occurred in the component:/) + ) + expect(mockConsoleError).toBeCalledTimes(2) + }) +}) diff --git a/src/dom/index.ts b/src/dom/index.ts index 7d558c25..39d6bc8f 100644 --- a/src/dom/index.ts +++ b/src/dom/index.ts @@ -1,5 +1,7 @@ import { autoRegisterCleanup } from '../core/cleanup' +import { enableErrorOutputSuppression } from '../helpers/console' autoRegisterCleanup() +enableErrorOutputSuppression() export * from './pure' diff --git a/src/dom/pure.ts b/src/dom/pure.ts index 3958dd93..51a5ab31 100644 --- a/src/dom/pure.ts +++ b/src/dom/pure.ts @@ -1,11 +1,14 @@ import ReactDOM from 'react-dom' -import { act } from 'react-dom/test-utils' +import { act as baseAct } from 'react-dom/test-utils' -import { RendererProps, RendererOptions } from '../types/react' +import { RendererProps, RendererOptions, Act } from '../types/react' import { createRenderHook } from '../core' +import { createActWrapper } from '../helpers/act' import { createTestHarness } from '../helpers/createTestHarness' +const act = createActWrapper(baseAct) + function createDomRenderer( rendererProps: RendererProps, { wrapper }: RendererOptions diff --git a/src/helpers/act.ts b/src/helpers/act.ts new file mode 100644 index 00000000..895560d9 --- /dev/null +++ b/src/helpers/act.ts @@ -0,0 +1,26 @@ +import { Act } from '../types/react' + +import { suppressErrorOutput } from './console' + +import { isPromise } from './promises' + +function createActWrapper(baseAct: Act) { + const act: Act = async (callback: () => unknown) => { + const restoreOutput = suppressErrorOutput() + try { + let awaitRequired = false + const actResult = baseAct(() => { + const callbackResult = callback() + awaitRequired = isPromise(callbackResult) + return callbackResult as Promise + }) + return awaitRequired ? await actResult : undefined + } finally { + restoreOutput() + } + } + + return act +} + +export { createActWrapper } diff --git a/src/helpers/console.ts b/src/helpers/console.ts new file mode 100644 index 00000000..08336ae4 --- /dev/null +++ b/src/helpers/console.ts @@ -0,0 +1,32 @@ +import filterConsole from 'filter-console' + +let errorOutputSuppressionEnabled = false + +function enableErrorOutputSuppression() { + errorOutputSuppressionEnabled = true +} + +function suppressErrorOutput() { + if (!errorOutputSuppressionEnabled || process.env.RHTL_DISABLE_ERROR_FILTERING) { + return () => {} + } + + // The error output from error boundaries is notoriously difficult to suppress. To save + // our users from having to work it out, we crudely suppress the output matching the patterns + // below. For more information, see these issues: + // - https://github.com/testing-library/react-hooks-testing-library/issues/50 + // - https://github.com/facebook/react/issues/11098#issuecomment-412682721 + // - https://github.com/facebook/react/issues/15520 + // - https://github.com/facebook/react/issues/18841 + return filterConsole( + [ + /^The above error occurred in the component:/, // error boundary output + /^Error: Uncaught .+/ // jsdom output + ], + { + methods: ['error'] + } + ) +} + +export { enableErrorOutputSuppression, suppressErrorOutput } diff --git a/src/helpers/createTestHarness.tsx b/src/helpers/createTestHarness.tsx index 0d1d4838..5ee7fb6b 100644 --- a/src/helpers/createTestHarness.tsx +++ b/src/helpers/createTestHarness.tsx @@ -1,31 +1,8 @@ import React, { Suspense } from 'react' import { ErrorBoundary, FallbackProps } from 'react-error-boundary' -import filterConsole from 'filter-console' - -import { addCleanup } from '../core' import { RendererProps, WrapperComponent } from '../types/react' -function suppressErrorOutput() { - // The error output from error boundaries is notoriously difficult to suppress. To save - // out users from having to work it out, we crudely suppress the output matching the patterns - // below. For more information, see these issues: - // - https://github.com/testing-library/react-hooks-testing-library/issues/50 - // - https://github.com/facebook/react/issues/11098#issuecomment-412682721 - // - https://github.com/facebook/react/issues/15520 - // - https://github.com/facebook/react/issues/18841 - const removeConsoleFilter = filterConsole( - [ - /^The above error occurred in the component:/, // error boundary output - /^Error: Uncaught .+/ // jsdom output - ], - { - methods: ['error'] - } - ) - addCleanup(removeConsoleFilter) -} - function createTestHarness( { callback, setValue, setError }: RendererProps, Wrapper?: WrapperComponent, @@ -47,8 +24,6 @@ function createTestHarness( return null } - suppressErrorOutput() - const testHarness = (props?: TProps) => { resetErrorBoundary() diff --git a/src/helpers/promises.ts b/src/helpers/promises.ts index 2fa89e5f..83e9d3a6 100644 --- a/src/helpers/promises.ts +++ b/src/helpers/promises.ts @@ -7,4 +7,8 @@ async function callAfter(callback: () => void, ms: number) { callback() } -export { resolveAfter, callAfter } +function isPromise(value: unknown): boolean { + return value !== undefined && typeof (value as PromiseLike).then === 'function' +} + +export { resolveAfter, callAfter, isPromise } diff --git a/src/index.ts b/src/index.ts index 10b0b905..f65d92b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { autoRegisterCleanup } from './core/cleanup' +import { enableErrorOutputSuppression } from './helpers/console' autoRegisterCleanup() +enableErrorOutputSuppression() export * from './pure' diff --git a/src/native/__tests__/errorHook.test.ts b/src/native/__tests__/errorHook.test.ts index c7f21847..69e54270 100644 --- a/src/native/__tests__/errorHook.test.ts +++ b/src/native/__tests__/errorHook.test.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { renderHook } from '..' +import { renderHook, act } from '..' describe('error hook tests', () => { function useError(throwError?: boolean) { @@ -142,4 +142,51 @@ describe('error hook tests', () => { expect(result.error).toBe(undefined) }) }) + + describe('error output suppression', () => { + test('should allow console.error to be mocked', async () => { + const consoleError = console.error + console.error = jest.fn() + + try { + const { rerender, unmount } = renderHook( + (stage) => { + useEffect(() => { + console.error(`expected in effect`) + return () => { + console.error(`expected in unmount`) + } + }, []) + console.error(`expected in ${stage}`) + }, + { + initialProps: 'render' + } + ) + + act(() => { + console.error('expected in act') + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + console.error('expected in async act') + }) + + rerender('rerender') + + unmount() + + expect(console.error).toBeCalledWith('expected in render') + expect(console.error).toBeCalledWith('expected in effect') + expect(console.error).toBeCalledWith('expected in act') + expect(console.error).toBeCalledWith('expected in async act') + expect(console.error).toBeCalledWith('expected in rerender') + expect(console.error).toBeCalledWith('expected in unmount') + expect(console.error).toBeCalledTimes(6) + } finally { + console.error = consoleError + } + }) + }) }) diff --git a/src/native/index.ts b/src/native/index.ts index 7d558c25..39d6bc8f 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,5 +1,7 @@ import { autoRegisterCleanup } from '../core/cleanup' +import { enableErrorOutputSuppression } from '../helpers/console' autoRegisterCleanup() +enableErrorOutputSuppression() export * from './pure' diff --git a/src/native/pure.ts b/src/native/pure.ts index 2ce742aa..38f058c5 100644 --- a/src/native/pure.ts +++ b/src/native/pure.ts @@ -1,10 +1,13 @@ -import { act, create, ReactTestRenderer } from 'react-test-renderer' +import { act as baseAct, create, ReactTestRenderer } from 'react-test-renderer' -import { RendererProps, RendererOptions } from '../types/react' +import { RendererProps, RendererOptions, Act } from '../types/react' import { createRenderHook } from '../core' +import { createActWrapper } from '../helpers/act' import { createTestHarness } from '../helpers/createTestHarness' +const act = createActWrapper(baseAct) + function createNativeRenderer( rendererProps: RendererProps, { wrapper }: RendererOptions diff --git a/src/server/__tests__/errorHook.test.ts b/src/server/__tests__/errorHook.test.ts index f7977465..f3ce0442 100644 --- a/src/server/__tests__/errorHook.test.ts +++ b/src/server/__tests__/errorHook.test.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { renderHook } from '..' +import { renderHook, act } from '..' describe('error hook tests', () => { function useError(throwError?: boolean) { @@ -163,4 +163,53 @@ describe('error hook tests', () => { expect(result.error).toBe(undefined) }) }) + + describe('error output suppression', () => { + test('should allow console.error to be mocked', async () => { + const consoleError = console.error + console.error = jest.fn() + + try { + const { hydrate, rerender, unmount } = renderHook( + (stage) => { + useEffect(() => { + console.error(`expected in effect`) + return () => { + console.error(`expected in unmount`) + } + }, []) + console.error(`expected in ${stage}`) + }, + { + initialProps: 'render' + } + ) + + hydrate() + + act(() => { + console.error('expected in act') + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + console.error('expected in async act') + }) + + rerender('rerender') + + unmount() + + expect(console.error).toBeCalledWith('expected in render') // twice render/hydrate + expect(console.error).toBeCalledWith('expected in effect') + expect(console.error).toBeCalledWith('expected in act') + expect(console.error).toBeCalledWith('expected in async act') + expect(console.error).toBeCalledWith('expected in rerender') + expect(console.error).toBeCalledWith('expected in unmount') + expect(console.error).toBeCalledTimes(7) + } finally { + console.error = consoleError + } + }) + }) }) diff --git a/src/server/index.ts b/src/server/index.ts index 7d558c25..39d6bc8f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,7 @@ import { autoRegisterCleanup } from '../core/cleanup' +import { enableErrorOutputSuppression } from '../helpers/console' autoRegisterCleanup() +enableErrorOutputSuppression() export * from './pure' diff --git a/src/server/pure.ts b/src/server/pure.ts index 3cd25a9d..a7061963 100644 --- a/src/server/pure.ts +++ b/src/server/pure.ts @@ -1,12 +1,15 @@ import ReactDOMServer from 'react-dom/server' import ReactDOM from 'react-dom' -import { act } from 'react-dom/test-utils' +import { act as baseAct } from 'react-dom/test-utils' -import { RendererProps, RendererOptions } from '../types/react' +import { RendererProps, RendererOptions, Act } from '../types/react' import { createRenderHook } from '../core' +import { createActWrapper } from '../helpers/act' import { createTestHarness } from '../helpers/createTestHarness' +const act = createActWrapper(baseAct) + function createServerRenderer( rendererProps: RendererProps, { wrapper }: RendererOptions