Skip to content

Commit 801ad37

Browse files
authored
test: Fail on unexpected console.warn and console.error (#1139)
1 parent 185e314 commit 801ad37

9 files changed

+510
-41
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@
5151
},
5252
"devDependencies": {
5353
"@testing-library/jest-dom": "^5.11.6",
54+
"chalk": "^4.1.2",
5455
"dotenv-cli": "^4.0.0",
56+
"jest-diff": "^27.5.1",
5557
"kcd-scripts": "^11.1.0",
5658
"npm-run-all": "^4.1.5",
5759
"react": "^18.0.0",

src/__tests__/cleanup.js

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe('fake timers and missing act warnings', () => {
5151
})
5252

5353
afterEach(() => {
54+
jest.restoreAllMocks()
5455
jest.useRealTimers()
5556
})
5657

src/__tests__/new-act.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ beforeEach(() => {
1313
})
1414

1515
afterEach(() => {
16-
console.error.mockRestore()
16+
jest.restoreAllMocks()
1717
})
1818

1919
test('async act works when it does not exist (older versions of react)', async () => {

src/__tests__/render.js

+14-23
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@ import ReactDOM from 'react-dom'
33
import ReactDOMServer from 'react-dom/server'
44
import {fireEvent, render, screen} from '../'
55

6-
afterEach(() => {
7-
if (console.error.mockRestore !== undefined) {
8-
console.error.mockRestore()
9-
}
10-
})
11-
126
test('renders div into document', () => {
137
const ref = React.createRef()
148
const {container} = render(<div ref={ref} />)
@@ -126,7 +120,6 @@ test('can be called multiple times on the same container', () => {
126120
})
127121

128122
test('hydrate will make the UI interactive', () => {
129-
jest.spyOn(console, 'error').mockImplementation(() => {})
130123
function App() {
131124
const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
132125

@@ -145,8 +138,6 @@ test('hydrate will make the UI interactive', () => {
145138

146139
render(ui, {container, hydrate: true})
147140

148-
expect(console.error).not.toHaveBeenCalled()
149-
150141
fireEvent.click(container.querySelector('button'))
151142

152143
expect(container).toHaveTextContent('clicked:1')
@@ -172,26 +163,26 @@ test('hydrate can have a wrapper', () => {
172163
})
173164

174165
test('legacyRoot uses legacy ReactDOM.render', () => {
175-
jest.spyOn(console, 'error').mockImplementation(() => {})
176-
render(<div />, {legacyRoot: true})
177-
178-
expect(console.error).toHaveBeenCalledTimes(1)
179-
expect(console.error).toHaveBeenNthCalledWith(
180-
1,
181-
"Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
166+
expect(() => {
167+
render(<div />, {legacyRoot: true})
168+
}).toErrorDev(
169+
[
170+
"Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
171+
],
172+
{withoutStack: true},
182173
)
183174
})
184175

185176
test('legacyRoot uses legacy ReactDOM.hydrate', () => {
186-
jest.spyOn(console, 'error').mockImplementation(() => {})
187177
const ui = <div />
188178
const container = document.createElement('div')
189179
container.innerHTML = ReactDOMServer.renderToString(ui)
190-
render(ui, {container, hydrate: true, legacyRoot: true})
191-
192-
expect(console.error).toHaveBeenCalledTimes(1)
193-
expect(console.error).toHaveBeenNthCalledWith(
194-
1,
195-
"Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
180+
expect(() => {
181+
render(ui, {container, hydrate: true, legacyRoot: true})
182+
}).toErrorDev(
183+
[
184+
"Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
185+
],
186+
{withoutStack: true},
196187
)
197188
})

src/__tests__/renderHook.js

+16-17
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,26 @@ test('allows wrapper components', async () => {
6262
})
6363

6464
test('legacyRoot uses legacy ReactDOM.render', () => {
65-
jest.spyOn(console, 'error').mockImplementation(() => {})
66-
6765
const Context = React.createContext('default')
6866
function Wrapper({children}) {
6967
return <Context.Provider value="provided">{children}</Context.Provider>
7068
}
71-
const {result} = renderHook(
72-
() => {
73-
return React.useContext(Context)
74-
},
75-
{
76-
wrapper: Wrapper,
77-
legacyRoot: true,
78-
},
69+
let result
70+
expect(() => {
71+
result = renderHook(
72+
() => {
73+
return React.useContext(Context)
74+
},
75+
{
76+
wrapper: Wrapper,
77+
legacyRoot: true,
78+
},
79+
).result
80+
}).toErrorDev(
81+
[
82+
"Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
83+
],
84+
{withoutStack: true},
7985
)
80-
8186
expect(result.current).toEqual('provided')
82-
83-
expect(console.error).toHaveBeenCalledTimes(1)
84-
expect(console.error).toHaveBeenNthCalledWith(
85-
1,
86-
"Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
87-
)
8887
})

tests/failOnUnexpectedConsoleCalls.js

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/setupTests.js#L71-L161
2+
/**
3+
MIT License
4+
5+
Copyright (c) Facebook, Inc. and its affiliates.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
*/
25+
/* eslint-disable prefer-template */
26+
/* eslint-disable func-names */
27+
const util = require('util')
28+
const chalk = require('chalk')
29+
const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
30+
31+
const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
32+
const newMethod = function (format, ...args) {
33+
// Ignore uncaught errors reported by jsdom
34+
// and React addendums because they're too noisy.
35+
if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
36+
return
37+
}
38+
39+
// Capture the call stack now so we can warn about it later.
40+
// The call stack has helpful information for the test author.
41+
// Don't throw yet though b'c it might be accidentally caught and suppressed.
42+
const stack = new Error().stack
43+
unexpectedConsoleCallStacks.push([
44+
stack.substr(stack.indexOf('\n') + 1),
45+
util.format(format, ...args),
46+
])
47+
}
48+
49+
console[methodName] = newMethod
50+
51+
return newMethod
52+
}
53+
54+
const isSpy = spy =>
55+
(spy.calls && typeof spy.calls.count === 'function') ||
56+
spy._isMockFunction === true
57+
58+
const flushUnexpectedConsoleCalls = (
59+
mockMethod,
60+
methodName,
61+
expectedMatcher,
62+
unexpectedConsoleCallStacks,
63+
) => {
64+
if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
65+
throw new Error(
66+
`Test did not tear down console.${methodName} mock properly.`,
67+
)
68+
}
69+
if (unexpectedConsoleCallStacks.length > 0) {
70+
const messages = unexpectedConsoleCallStacks.map(
71+
([stack, message]) =>
72+
`${chalk.red(message)}\n` +
73+
`${stack
74+
.split('\n')
75+
.map(line => chalk.gray(line))
76+
.join('\n')}`,
77+
)
78+
79+
const message =
80+
`Expected test not to call ${chalk.bold(
81+
`console.${methodName}()`,
82+
)}.\n\n` +
83+
'If the warning is expected, test for it explicitly by:\n' +
84+
`1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
85+
`matcher, or...\n` +
86+
`2. Mock it out using ${chalk.bold(
87+
'spyOnDev',
88+
)}(console, '${methodName}') or ${chalk.bold(
89+
'spyOnProd',
90+
)}(console, '${methodName}'), and test that the warning occurs.`
91+
92+
throw new Error(`${message}\n\n${messages.join('\n\n')}`)
93+
}
94+
}
95+
96+
const unexpectedErrorCallStacks = []
97+
const unexpectedWarnCallStacks = []
98+
99+
const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks)
100+
const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks)
101+
102+
const flushAllUnexpectedConsoleCalls = () => {
103+
flushUnexpectedConsoleCalls(
104+
errorMethod,
105+
'error',
106+
'toErrorDev',
107+
unexpectedErrorCallStacks,
108+
)
109+
flushUnexpectedConsoleCalls(
110+
warnMethod,
111+
'warn',
112+
'toWarnDev',
113+
unexpectedWarnCallStacks,
114+
)
115+
unexpectedErrorCallStacks.length = 0
116+
unexpectedWarnCallStacks.length = 0
117+
}
118+
119+
const resetAllUnexpectedConsoleCalls = () => {
120+
unexpectedErrorCallStacks.length = 0
121+
unexpectedWarnCallStacks.length = 0
122+
}
123+
124+
expect.extend({
125+
...require('./toWarnDev'),
126+
})
127+
128+
beforeEach(resetAllUnexpectedConsoleCalls)
129+
afterEach(flushAllUnexpectedConsoleCalls)

tests/setup-env.js

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import '@testing-library/jest-dom/extend-expect'
2+
import './failOnUnexpectedConsoleCalls'

tests/shouldIgnoreConsoleError.js

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/shouldIgnoreConsoleError.js
2+
/**
3+
MIT License
4+
5+
Copyright (c) Facebook, Inc. and its affiliates.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
*/
25+
26+
module.exports = function shouldIgnoreConsoleError(format) {
27+
if (process.env.NODE_ENV !== 'production') {
28+
if (typeof format === 'string') {
29+
if (format.indexOf('Error: Uncaught [') === 0) {
30+
// This looks like an uncaught error from invokeGuardedCallback() wrapper
31+
// in development that is reported by jsdom. Ignore because it's noisy.
32+
return true
33+
}
34+
if (format.indexOf('The above error occurred') === 0) {
35+
// This looks like an error addendum from ReactFiberErrorLogger.
36+
// Ignore it too.
37+
return true
38+
}
39+
}
40+
}
41+
// Looks legit
42+
return false
43+
}

0 commit comments

Comments
 (0)