Skip to content

Commit 2e7b588

Browse files
committed
fix: only suppress console.error for non-pure imports
Fixes #546
1 parent b87c30c commit 2e7b588

15 files changed

+310
-32
lines changed

disable-error-filtering.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
process.env.RHTL_DISABLE_ERROR_FILTERING = true

docs/api-reference.md

+48-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ route: '/reference/api'
1212
- [`cleanup`](/reference/api#cleanup)
1313
- [`addCleanup`](/reference/api#addcleanup)
1414
- [`removeCleanup`](/reference/api#removecleanup)
15+
- [`console.error`](/reference/api#consoleerror)
1516

1617
---
1718

@@ -154,7 +155,7 @@ module.exports = {
154155
```
155156

156157
Alternatively, you can change your test to import from `@testing-library/react-hooks/pure` instead
157-
of the regular imports. This applys to any of our export methods documented in
158+
of the regular imports. This applies to any of our export methods documented in
158159
[Rendering](/installation#being-specific).
159160

160161
```diff
@@ -270,3 +271,49 @@ Interval checking is disabled if `interval` is not provided as a `falsy`.
270271
_Default: 1000_
271272

272273
The maximum amount of time in milliseconds (ms) to wait.
274+
275+
---
276+
277+
## `console.error`
278+
279+
In order to catch errors that are produced in all parts of the hook's lifecycle, the test harness
280+
used to wrap the hook call includes an
281+
[Error Boundary](https://reactjs.org/docs/error-boundaries.html) which causes a
282+
[significant amount of output noise](https://reactjs.org/docs/error-boundaries.html#component-stack-traces)
283+
in tests.
284+
285+
To keep test output clean, we patch `console.error` when `renderHook` is called to filter out the
286+
unnecessary logging and restore the original version during cleanup. This side-effect can affect
287+
tests that also patch `console.error` (e.g. to assert a specific error message get logged) by
288+
replacing their custom implementation as well.
289+
290+
### Disabling `console.error` filtering
291+
292+
Importing `@testing-library/react-hooks/disable-error-filtering.js` in test setup files disable the
293+
error filtering feature and not patch `console.error` in any way.
294+
295+
For example, in [Jest](https://jestjs.io/) this can be added to your
296+
[Jest config](https://jestjs.io/docs/configuration):
297+
298+
```js
299+
module.exports = {
300+
setupFilesAfterEnv: [
301+
'@testing-library/react-hooks/disable-error-filtering.js'
302+
// other setup files
303+
]
304+
}
305+
```
306+
307+
Alternatively, you can change your test to import from `@testing-library/react-hooks/pure` instead
308+
of the regular imports. This applies to any of our export methods documented in
309+
[Rendering](/installation#being-specific).
310+
311+
```diff
312+
- import { renderHook, cleanup, act } from '@testing-library/react-hooks'
313+
+ import { renderHook, cleanup, act } from '@testing-library/react-hooks/pure'
314+
```
315+
316+
If neither of these approaches are suitable, setting the `RHTL_DISABLE_ERROR_FILTERING` environment
317+
variable to `true` before importing `@testing-library/react-hooks` will also disable this feature.
318+
319+
> Please note that this may result is a significant amount of additional logging in you test output.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"native",
1919
"server",
2020
"pure",
21+
"disable-error-filtering.js",
2122
"dont-cleanup-after-each.js"
2223
],
2324
"author": "Michael Peyper <[email protected]>",

src/core/console.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import filterConsole from 'filter-console'
2+
3+
function enableErrorOutputSuppression() {
4+
if (!process.env.RHTL_DISABLE_ERROR_FILTERING) {
5+
const restoreConsole = filterConsole(
6+
[
7+
/^The above error occurred in the <TestComponent> component:/, // error boundary output
8+
/^Error: Uncaught .+/ // jsdom output
9+
],
10+
{
11+
methods: ['error']
12+
}
13+
)
14+
15+
// Automatically registers restoration in supported testing frameworks
16+
if (typeof afterAll === 'function') {
17+
afterAll(async () => {
18+
await new Promise((resolve) => setTimeout(resolve, 100))
19+
restoreConsole()
20+
})
21+
}
22+
}
23+
}
24+
25+
export { enableErrorOutputSuppression }

src/dom/__tests__/errorHook.test.ts

+50-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react'
2-
import { renderHook } from '..'
2+
import { renderHook, act } from '..'
33

44
describe('error hook tests', () => {
55
function useError(throwError?: boolean) {
@@ -109,15 +109,15 @@ describe('error hook tests', () => {
109109
})
110110

111111
describe('effect', () => {
112-
test('should raise effect error', () => {
112+
test('this one - should raise effect error', () => {
113113
const { result } = renderHook(() => useEffectError(true))
114114

115115
expect(() => {
116116
expect(result.current).not.toBe(undefined)
117117
}).toThrow(Error('expected'))
118118
})
119119

120-
test('should capture effect error', () => {
120+
test('this one - should capture effect error', () => {
121121
const { result } = renderHook(() => useEffectError(true))
122122
expect(result.error).toEqual(Error('expected'))
123123
})
@@ -142,4 +142,51 @@ describe('error hook tests', () => {
142142
expect(result.error).toBe(undefined)
143143
})
144144
})
145+
146+
describe('error output suppression', () => {
147+
test('should allow console.error to be mocked', async () => {
148+
const consoleError = console.error
149+
console.error = jest.fn()
150+
151+
try {
152+
const { rerender, unmount } = renderHook(
153+
(stage) => {
154+
useEffect(() => {
155+
console.error(`expected in effect`)
156+
return () => {
157+
console.error(`expected in unmount`)
158+
}
159+
}, [])
160+
console.error(`expected in ${stage}`)
161+
},
162+
{
163+
initialProps: 'render'
164+
}
165+
)
166+
167+
act(() => {
168+
console.error('expected in act')
169+
})
170+
171+
await act(async () => {
172+
await new Promise((resolve) => setTimeout(resolve, 100))
173+
console.error('expected in async act')
174+
})
175+
176+
rerender('rerender')
177+
178+
unmount()
179+
180+
expect(console.error).toBeCalledWith('expected in render')
181+
expect(console.error).toBeCalledWith('expected in effect')
182+
expect(console.error).toBeCalledWith('expected in act')
183+
expect(console.error).toBeCalledWith('expected in async act')
184+
expect(console.error).toBeCalledWith('expected in rerender')
185+
expect(console.error).toBeCalledWith('expected in unmount')
186+
expect(console.error).toBeCalledTimes(6)
187+
} finally {
188+
console.error = consoleError
189+
}
190+
})
191+
})
145192
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { renderHook } from '..'
2+
3+
describe('error output suppression (disabled) tests', () => {
4+
function useError(throwError?: boolean) {
5+
if (throwError) {
6+
throw new Error('expected')
7+
}
8+
return true
9+
}
10+
11+
const originalConsoleError = console.error
12+
const mockConsoleError = jest.fn()
13+
14+
beforeAll(() => {
15+
process.env.RHTL_DISABLE_ERROR_FILTERING = 'true'
16+
})
17+
18+
beforeEach(() => {
19+
console.error = mockConsoleError
20+
})
21+
22+
afterEach(() => {
23+
console.error = originalConsoleError
24+
})
25+
26+
test('should not suppress error output', () => {
27+
const { result } = renderHook(() => useError(true))
28+
29+
expect(result.error).toEqual(Error('expected'))
30+
expect(mockConsoleError).toBeCalledWith(
31+
expect.stringMatching(/^Error: Uncaught \[Error: expected\]/),
32+
expect.any(Error)
33+
)
34+
expect(mockConsoleError).toBeCalledWith(
35+
expect.stringMatching(/^The above error occurred in the <TestComponent> component:/)
36+
)
37+
expect(mockConsoleError).toBeCalledTimes(2)
38+
})
39+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { renderHook } from '../pure'
2+
3+
describe('error output suppression (pure) tests', () => {
4+
function useError(throwError?: boolean) {
5+
if (throwError) {
6+
throw new Error('expected')
7+
}
8+
return true
9+
}
10+
11+
const originalConsoleError = console.error
12+
const mockConsoleError = jest.fn()
13+
14+
beforeEach(() => {
15+
console.error = mockConsoleError
16+
})
17+
18+
afterEach(() => {
19+
console.error = originalConsoleError
20+
})
21+
22+
test('should not suppress error output', () => {
23+
const { result } = renderHook(() => useError(true))
24+
25+
expect(result.error).toEqual(Error('expected'))
26+
expect(mockConsoleError).toBeCalledWith(
27+
expect.stringMatching(/^Error: Uncaught \[Error: expected\]/),
28+
expect.any(Error)
29+
)
30+
expect(mockConsoleError).toBeCalledWith(
31+
expect.stringMatching(/^The above error occurred in the <TestComponent> component:/)
32+
)
33+
expect(mockConsoleError).toBeCalledTimes(2)
34+
})
35+
})

src/dom/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { autoRegisterCleanup } from '../core/cleanup'
2+
import { enableErrorOutputSuppression } from '../core/console'
23

34
autoRegisterCleanup()
5+
enableErrorOutputSuppression()
46

57
export * from './pure'

src/helpers/createTestHarness.tsx

-25
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,8 @@
11
import React, { Suspense } from 'react'
22
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
3-
import filterConsole from 'filter-console'
4-
5-
import { addCleanup } from '../core'
63

74
import { RendererProps, WrapperComponent } from '../types/react'
85

9-
function suppressErrorOutput() {
10-
// The error output from error boundaries is notoriously difficult to suppress. To save
11-
// out users from having to work it out, we crudely suppress the output matching the patterns
12-
// below. For more information, see these issues:
13-
// - https://github.com/testing-library/react-hooks-testing-library/issues/50
14-
// - https://github.com/facebook/react/issues/11098#issuecomment-412682721
15-
// - https://github.com/facebook/react/issues/15520
16-
// - https://github.com/facebook/react/issues/18841
17-
const removeConsoleFilter = filterConsole(
18-
[
19-
/^The above error occurred in the <TestComponent> component:/, // error boundary output
20-
/^Error: Uncaught .+/ // jsdom output
21-
],
22-
{
23-
methods: ['error']
24-
}
25-
)
26-
addCleanup(removeConsoleFilter)
27-
}
28-
296
function createTestHarness<TProps, TResult>(
307
{ callback, setValue, setError }: RendererProps<TProps, TResult>,
318
Wrapper?: WrapperComponent<TProps>,
@@ -47,8 +24,6 @@ function createTestHarness<TProps, TResult>(
4724
return null
4825
}
4926

50-
suppressErrorOutput()
51-
5227
const testHarness = (props?: TProps) => {
5328
resetErrorBoundary()
5429

src/helpers/promises.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ async function callAfter(callback: () => void, ms: number) {
77
callback()
88
}
99

10-
export { resolveAfter, callAfter }
10+
function isPromise(value: unknown): boolean {
11+
return value !== undefined && typeof (value as PromiseLike<unknown>).then === 'function'
12+
}
13+
14+
export { resolveAfter, callAfter, isPromise }

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { autoRegisterCleanup } from './core/cleanup'
2+
import { enableErrorOutputSuppression } from './core/console'
23

34
autoRegisterCleanup()
5+
enableErrorOutputSuppression()
46

57
export * from './pure'

src/native/__tests__/errorHook.test.ts

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react'
2-
import { renderHook } from '..'
2+
import { renderHook, act } from '..'
33

44
describe('error hook tests', () => {
55
function useError(throwError?: boolean) {
@@ -142,4 +142,51 @@ describe('error hook tests', () => {
142142
expect(result.error).toBe(undefined)
143143
})
144144
})
145+
146+
describe('error output suppression', () => {
147+
test('should allow console.error to be mocked', async () => {
148+
const consoleError = console.error
149+
console.error = jest.fn()
150+
151+
try {
152+
const { rerender, unmount } = renderHook(
153+
(stage) => {
154+
useEffect(() => {
155+
console.error(`expected in effect`)
156+
return () => {
157+
console.error(`expected in unmount`)
158+
}
159+
}, [])
160+
console.error(`expected in ${stage}`)
161+
},
162+
{
163+
initialProps: 'render'
164+
}
165+
)
166+
167+
act(() => {
168+
console.error('expected in act')
169+
})
170+
171+
await act(async () => {
172+
await new Promise((resolve) => setTimeout(resolve, 100))
173+
console.error('expected in async act')
174+
})
175+
176+
rerender('rerender')
177+
178+
unmount()
179+
180+
expect(console.error).toBeCalledWith('expected in render')
181+
expect(console.error).toBeCalledWith('expected in effect')
182+
expect(console.error).toBeCalledWith('expected in act')
183+
expect(console.error).toBeCalledWith('expected in async act')
184+
expect(console.error).toBeCalledWith('expected in rerender')
185+
expect(console.error).toBeCalledWith('expected in unmount')
186+
expect(console.error).toBeCalledTimes(6)
187+
} finally {
188+
console.error = consoleError
189+
}
190+
})
191+
})
145192
})

src/native/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { autoRegisterCleanup } from '../core/cleanup'
2+
import { enableErrorOutputSuppression } from '../core/console'
23

34
autoRegisterCleanup()
5+
enableErrorOutputSuppression()
46

57
export * from './pure'

0 commit comments

Comments
 (0)