forked from cardosofelipe/fast-next-template
test(frontend): add comprehensive ErrorBoundary tests
- Test normal rendering of children when no error - Test error catching and default fallback UI display - Test custom fallback rendering - Test onError callback invocation - Test reset functionality to recover from errors - Test showReset prop behavior - Test accessibility features (aria-hidden, descriptive text) - Test edge cases: deeply nested errors, error isolation, nested boundaries Coverage: 94.73% statements, 100% branches/functions/lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
401
frontend/tests/components/common/ErrorBoundary.test.tsx
Normal file
401
frontend/tests/components/common/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* ErrorBoundary Component Tests
|
||||
*
|
||||
* Tests for the error boundary component that catches JavaScript errors
|
||||
* in child components and displays fallback UI.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
|
||||
// Component that throws an error for testing
|
||||
function ThrowingComponent({ shouldThrow = true }: { shouldThrow?: boolean }) {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error message');
|
||||
}
|
||||
return <div>Child content</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
// Suppress console.error during tests since we're testing error handling
|
||||
const originalError = console.error;
|
||||
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when no error occurs', () => {
|
||||
it('renders children normally', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show error UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>First child</div>
|
||||
<div>Second child</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('First child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an error occurs', () => {
|
||||
it('catches the error and shows default fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText(/An unexpected error occurred/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the error message', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the reset button by default', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('logs error to console', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'ErrorBoundary caught an error:',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ componentStack: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the throwing component', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Child content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom fallback', () => {
|
||||
it('renders custom fallback when provided', () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom error UI</div>}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom error UI')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show default fallback when custom fallback is provided', () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<span>Fallback</span>}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError callback', () => {
|
||||
it('calls onError when an error occurs', () => {
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Test error message' }),
|
||||
expect.objectContaining({ componentStack: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call onError when no error occurs', () => {
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<div>Normal content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset functionality', () => {
|
||||
it('resets error state when reset button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Track whether we should throw
|
||||
let shouldThrow = true;
|
||||
|
||||
function ResettableThrowingComponent() {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Resettable error');
|
||||
}
|
||||
return <div>Recovered content</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ResettableThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Should show error UI after first render throws
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resettable error')).toBeInTheDocument();
|
||||
|
||||
// Stop throwing before clicking reset
|
||||
shouldThrow = false;
|
||||
|
||||
// Click reset
|
||||
await user.click(screen.getByRole('button', { name: /try again/i }));
|
||||
|
||||
// After reset, the component re-renders without throwing
|
||||
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Recovered content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showReset prop', () => {
|
||||
it('hides reset button when showReset is false', () => {
|
||||
render(
|
||||
<ErrorBoundary showReset={false}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reset button when showReset is true', () => {
|
||||
render(
|
||||
<ErrorBoundary showReset={true}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reset button by default (showReset undefined)', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error without message', () => {
|
||||
it('handles error with null message gracefully', () => {
|
||||
function ThrowNullError() {
|
||||
const error = new Error();
|
||||
error.message = '';
|
||||
throw error;
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowNullError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
// Empty message should still render the error container but be empty
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has descriptive error title', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has descriptive help text', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/An unexpected error occurred. Please try again or contact support/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('icons have aria-hidden', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const icons = document.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reset button is keyboard accessible', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// First render without error to set up the component
|
||||
expect(screen.getByText('Child content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultFallback rendering', () => {
|
||||
it('shows error in monospace font', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const errorMessage = screen.getByText('Test error message');
|
||||
expect(errorMessage).toHaveClass('font-mono');
|
||||
});
|
||||
|
||||
it('renders within a Card component', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// The card should have destructive styling
|
||||
const card = document.querySelector('.border-destructive\\/50');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles deeply nested errors', () => {
|
||||
function DeepChild() {
|
||||
throw new Error('Deep error');
|
||||
}
|
||||
|
||||
function MiddleComponent() {
|
||||
return (
|
||||
<div>
|
||||
<DeepChild />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>
|
||||
<MiddleComponent />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deep error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isolates errors to its own boundary', () => {
|
||||
render(
|
||||
<div>
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
<div>Sibling content</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sibling content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows nested error boundaries', () => {
|
||||
function InnerThrowing() {
|
||||
throw new Error('Inner error');
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Outer fallback</div>}>
|
||||
<div>
|
||||
<ErrorBoundary fallback={<div>Inner fallback</div>}>
|
||||
<InnerThrowing />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Inner boundary should catch the error
|
||||
expect(screen.getByText('Inner fallback')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Outer fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user