1
0
mirror of synced 2025-12-19 18:11:23 -05:00
Files
blitz/nextjs/test/unit/error-boundary.unit.test.tsx

462 lines
14 KiB
TypeScript

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import type { ErrorFallbackProps } from 'next/stdlib'
import { ErrorBoundary, withErrorBoundary } from 'next/stdlib'
afterEach(() => {
jest.resetAllMocks()
jest.restoreAllMocks()
})
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
// afterEach(() => {
// try {
// expect(console.error).not.toHaveBeenCalled()
// } catch (e) {
// throw new Error(
// `console.error was called unexpectedly (make sure to assert all calls and console.error.mockClear() at the end of the test)`,
// )
// }
// })
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
function Bomb() {
throw new Error('💥 CABOOM 💥')
// eslint-disable-next-line
return null
}
const firstLine = (str: string) => str.split('\n')[0]
export const cleanStack = (stack: any): any => {
if (typeof stack === 'string') {
return stack.replace(/\(.*\)/g, '')
}
if (typeof stack === 'object' && stack.componentStack) {
stack.componentStack = cleanStack(stack.componentStack)
return stack
}
return stack
}
test('standard use-case', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
function App() {
const [username, setUsername] = React.useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setUsername(e.target.value)
}
return (
<div>
<div>
<label htmlFor="username">Username</label>
<input type="text" id="username" onChange={handleChange} />
</div>
<div>{username === 'fail' ? 'Oh no' : 'things are good'}</div>
<div>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{username === 'fail' ? <Bomb /> : 'type "fail"'}
</ErrorBoundary>
</div>
</div>
)
}
render(<App />)
userEvent.type(screen.getByRole('textbox', { name: /username/i }), 'fail')
const [[actualError], [componentStack]] = consoleError.mock.calls
expect(firstLine(actualError as string)).toMatchInlineSnapshot(
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`
)
expect(cleanStack(componentStack)).toMatchInlineSnapshot(`
"The above error occurred in the <Bomb> component:
at Bomb
at ErrorBoundary
at div
at div
at App
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
`)
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
expect(screen.getByRole('alert')).toMatchInlineSnapshot(`
<div
role="alert"
>
<p>
Something went wrong:
</p>
<pre>
💥 CABOOM 💥
</pre>
<button>
Try again
</button>
</div>
`)
// can recover from errors when the component is rerendered and reset is clicked
userEvent.type(screen.getByRole('textbox', { name: /username/i }), '-not')
userEvent.click(screen.getByRole('button', { name: /try again/i }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
test('fallbackRender prop', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const workingMessage = 'Phew, we are safe!'
function App() {
const [explode, setExplode] = React.useState(true)
return (
<div>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<button
onClick={() => {
setExplode(false)
resetErrorBoundary()
}}
>
Try again
</button>
)}
>
{explode ? <Bomb /> : workingMessage}
</ErrorBoundary>
</div>
)
}
render(<App />)
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// the render prop API allows a single action to reset the app state
// as well as reset the ErrorBoundary state
userEvent.click(screen.getByRole('button', { name: /try again/i }))
expect(screen.getByText(workingMessage)).toBeInTheDocument()
})
test('simple fallback is supported', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
render(
<ErrorBoundary fallback={<div>Oh no</div>}>
<Bomb />
<span>child</span>
</ErrorBoundary>
)
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
expect(screen.getByText(/oh no/i)).toBeInTheDocument()
expect(screen.queryByText(/child/i)).not.toBeInTheDocument()
})
test('withErrorBoundary HOC', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const onErrorHandler = jest.fn()
const Boundary = withErrorBoundary(
() => {
throw new Error('💥 CABOOM 💥')
},
{ FallbackComponent: ErrorFallback, onError: onErrorHandler }
)
render(<Boundary />)
const [[actualError], [componentStack]] = consoleError.mock.calls
const firstLineOfError = firstLine(actualError as string)
expect(firstLineOfError).toMatchInlineSnapshot(
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`
)
expect(cleanStack(componentStack)).toMatchInlineSnapshot(`
"The above error occurred in one of your React components:
at Boundary.FallbackComponent
at ErrorBoundary
at withErrorBoundary
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
`)
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
const [error, onErrorComponentStack] = (onErrorHandler.mock.calls as [
[Error, string]
])[0]
expect(error.message).toMatchInlineSnapshot(`"💥 CABOOM 💥"`)
expect(cleanStack(onErrorComponentStack)).toMatchInlineSnapshot(`
Object {
"componentStack": "
at Boundary.FallbackComponent
at ErrorBoundary
at withErrorBoundary",
}
`)
expect(onErrorHandler).toHaveBeenCalledTimes(1)
})
test('supported but undocumented reset method', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const children = 'Boundry children'
function App() {
const errorBoundaryRef = React.useRef<ErrorBoundary | null>(null)
const [explode, setExplode] = React.useState(false)
return (
<>
<button onClick={() => setExplode(true)}>explode</button>
<button
onClick={() => {
setExplode(false)
errorBoundaryRef.current?.resetErrorBoundary()
}}
>
recover
</button>
<ErrorBoundary ref={errorBoundaryRef} FallbackComponent={ErrorFallback}>
{explode ? <Bomb /> : children}
</ErrorBoundary>
</>
)
}
render(<App />)
userEvent.click(screen.getByText('explode'))
expect(screen.queryByText(children)).not.toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
userEvent.click(screen.getByText('recover'))
expect(screen.getByText(children)).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(0)
})
test('requires either a fallback, fallbackRender, or FallbackComponent', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
expect(() =>
render(
// @ts-expect-error we're testing the runtime check of missing props here
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
)
).toThrowErrorMatchingInlineSnapshot(
`"<ErrorBoundary> requires either a fallback, fallbackRender, or FallbackComponent prop"`
)
consoleError.mockClear()
})
// eslint-disable-next-line max-statements
test('supports automatic reset of error boundary when resetKeys change', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const handleReset = jest.fn()
const TRY_AGAIN_ARG1 = 'TRY_AGAIN_ARG1'
const TRY_AGAIN_ARG2 = 'TRY_AGAIN_ARG2'
const handleResetKeysChange = jest.fn()
function App() {
const [explode, setExplode] = React.useState(false)
const [extra, setExtra] = React.useState(false)
return (
<div>
<button onClick={() => setExplode((e) => !e)}>toggle explode</button>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<div role="alert">
<button
onClick={() =>
resetErrorBoundary(TRY_AGAIN_ARG1, TRY_AGAIN_ARG2)
}
>
Try again
</button>
<button onClick={() => setExtra((e) => !e)}>
toggle extra resetKey
</button>
</div>
)}
onReset={(...args) => {
setExplode(false)
handleReset(...args)
}}
onResetKeysChange={handleResetKeysChange}
resetKeys={extra ? [explode, extra] : [explode]}
>
{explode || extra ? <Bomb /> : null}
</ErrorBoundary>
</div>
)
}
render(<App />)
// blow it up
userEvent.click(screen.getByText('toggle explode'))
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// recover via try again button
userEvent.click(screen.getByText(/try again/i))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
expect(handleReset).toHaveBeenCalledWith(TRY_AGAIN_ARG1, TRY_AGAIN_ARG2)
expect(handleReset).toHaveBeenCalledTimes(1)
handleReset.mockClear()
expect(handleResetKeysChange).not.toHaveBeenCalled()
// blow it up again
userEvent.click(screen.getByText('toggle explode'))
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// recover via resetKeys change
userEvent.click(screen.getByText('toggle explode'))
expect(handleResetKeysChange).toHaveBeenCalledWith([true], [false])
expect(handleResetKeysChange).toHaveBeenCalledTimes(1)
handleResetKeysChange.mockClear()
expect(handleReset).not.toHaveBeenCalled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
// blow it up again
userEvent.click(screen.getByText('toggle explode'))
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// toggles adding an extra resetKey to the array
// expect error to re-render
userEvent.click(screen.getByText('toggle extra resetKey'))
expect(handleResetKeysChange).toHaveBeenCalledTimes(1)
expect(handleResetKeysChange).toHaveBeenCalledWith([true], [true, true])
handleResetKeysChange.mockClear()
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// toggle explode back to false
// expect error to re-render again
userEvent.click(screen.getByText('toggle explode'))
expect(handleReset).not.toHaveBeenCalled()
expect(handleResetKeysChange).toHaveBeenCalledTimes(1)
expect(handleResetKeysChange).toHaveBeenCalledWith(
[true, true],
[false, true]
)
expect(screen.getByRole('alert')).toBeInTheDocument()
handleResetKeysChange.mockClear()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// toggle extra resetKey
// expect error to be reset
userEvent.click(screen.getByText('toggle extra resetKey'))
expect(handleReset).not.toHaveBeenCalled()
expect(handleResetKeysChange).toHaveBeenCalledTimes(1)
expect(handleResetKeysChange).toHaveBeenCalledWith([false, true], [false])
handleResetKeysChange.mockClear()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
test('supports reset via resetKeys right after error is triggered on component mount', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const handleResetKeysChange = jest.fn()
function App() {
const [explode, setExplode] = React.useState(true)
return (
<div>
<button onClick={() => setExplode((e) => !e)}>toggle explode</button>
<ErrorBoundary
fallbackRender={() => (
<div role="alert">
<p>Something went wrong</p>
</div>
)}
onResetKeysChange={handleResetKeysChange}
resetKeys={[explode]}
>
{explode ? <Bomb /> : null}
</ErrorBoundary>
</div>
)
}
render(<App />)
// it blows up on render
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledTimes(2)
consoleError.mockClear()
// recover via "toggle explode" button
userEvent.click(screen.getByText('toggle explode'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
expect(handleResetKeysChange).toHaveBeenCalledWith([true], [false])
expect(handleResetKeysChange).toHaveBeenCalledTimes(1)
})
test('should support not only function as FallbackComponent', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
const FancyFallback = React.forwardRef(({ error }: FallbackProps) => (
<div>
<p>Everything is broken. Try again</p>
<pre>{error.message}</pre>
</div>
))
FancyFallback.displayName = 'FancyFallback'
expect(() =>
render(
<ErrorBoundary FallbackComponent={FancyFallback}>
<Bomb />
</ErrorBoundary>
)
).not.toThrow()
expect(
screen.getByText('Everything is broken. Try again')
).toBeInTheDocument()
consoleError.mockClear()
})
test('should throw error if FallbackComponent is not valid', () => {
const consoleError = console.error as jest.Mock<void, unknown[]>
expect(() =>
render(
// @ts-expect-error we're testing the error case
<ErrorBoundary FallbackComponent={{}}>
<Bomb />
</ErrorBoundary>
)
).toThrowError(/Element type is invalid/i)
consoleError.mockClear()
})