- Add networkErrorRecovery utility with: - Request timeout handling via AbortController - Circuit breaker pattern to prevent cascading failures - Request deduplication for concurrent identical requests - Enhanced fetch with timeout, circuit breaker, and retry support - Add useApiWithErrorHandling hooks: - useApiCall for single API calls with auto error display - useMutation for mutations with optimistic update support - useMultipleApiCalls for parallel API execution - Add ErrorBoundary component: - Catches React errors in component tree - Displays fallback UI with retry option - Supports custom fallback components - withErrorBoundary HOC for easy wrapping - Add comprehensive tests (64 passing tests): - Circuit breaker state transitions - Request deduplication - Timeout detection - Error type classification - Hook behavior and error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
5.8 KiB
TypeScript
219 lines
5.8 KiB
TypeScript
/**
|
|
* Tests for ErrorBoundary component
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { render, fireEvent } from '@testing-library/react-native';
|
|
import { Text, View } from 'react-native';
|
|
import { ErrorBoundary, withErrorBoundary } from '../ErrorBoundary';
|
|
|
|
// Component that throws an error
|
|
function ThrowingComponent({ shouldThrow = true }: { shouldThrow?: boolean }) {
|
|
if (shouldThrow) {
|
|
throw new Error('Test error');
|
|
}
|
|
return <Text>Content rendered</Text>;
|
|
}
|
|
|
|
// Suppress console errors during tests
|
|
const originalConsoleError = console.error;
|
|
beforeAll(() => {
|
|
console.error = jest.fn();
|
|
});
|
|
afterAll(() => {
|
|
console.error = originalConsoleError;
|
|
});
|
|
|
|
describe('ErrorBoundary', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should render children when no error occurs', () => {
|
|
const { getByText } = render(
|
|
<ErrorBoundary>
|
|
<Text>Hello World</Text>
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Hello World')).toBeTruthy();
|
|
});
|
|
|
|
it('should render fallback UI when error occurs', () => {
|
|
const { getByText } = render(
|
|
<ErrorBoundary>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Oops! Something went wrong')).toBeTruthy();
|
|
expect(getByText(/The app encountered an unexpected error/)).toBeTruthy();
|
|
});
|
|
|
|
it('should render custom fallback when provided', () => {
|
|
const { getByText } = render(
|
|
<ErrorBoundary fallback={<Text>Custom Error UI</Text>}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Custom Error UI')).toBeTruthy();
|
|
});
|
|
|
|
it('should call onError callback when error occurs', () => {
|
|
const onError = jest.fn();
|
|
|
|
render(
|
|
<ErrorBoundary onError={onError}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
expect.objectContaining({
|
|
componentStack: expect.any(String),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should provide retry functionality', () => {
|
|
const onRetry = jest.fn();
|
|
|
|
const { getByText } = render(
|
|
<ErrorBoundary onRetry={onRetry}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
const tryAgainButton = getByText('Try Again');
|
|
fireEvent.press(tryAgainButton);
|
|
|
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should reset error state when retry is pressed', () => {
|
|
let shouldThrow = true;
|
|
|
|
const TestComponent = () => {
|
|
if (shouldThrow) {
|
|
throw new Error('Test');
|
|
}
|
|
return <Text>Recovered</Text>;
|
|
};
|
|
|
|
const { getByText, queryByText, rerender } = render(
|
|
<ErrorBoundary onRetry={() => { shouldThrow = false; }}>
|
|
<TestComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
// Error is shown
|
|
expect(getByText('Oops! Something went wrong')).toBeTruthy();
|
|
|
|
// Press retry
|
|
fireEvent.press(getByText('Try Again'));
|
|
|
|
// Re-render to see if it recovers
|
|
rerender(
|
|
<ErrorBoundary>
|
|
<TestComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Recovered')).toBeTruthy();
|
|
expect(queryByText('Oops! Something went wrong')).toBeNull();
|
|
});
|
|
|
|
it('should reset error state when resetKey changes', () => {
|
|
const { rerender, getByText, queryByText } = render(
|
|
<ErrorBoundary resetKey="key1">
|
|
<ThrowingComponent shouldThrow={true} />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Oops! Something went wrong')).toBeTruthy();
|
|
|
|
// Change resetKey and provide non-throwing child
|
|
rerender(
|
|
<ErrorBoundary resetKey="key2">
|
|
<ThrowingComponent shouldThrow={false} />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(getByText('Content rendered')).toBeTruthy();
|
|
expect(queryByText('Oops! Something went wrong')).toBeNull();
|
|
});
|
|
|
|
it('should show error details in development mode', () => {
|
|
const { getByText, queryByText } = render(
|
|
<ErrorBoundary showDetails={true}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
// Details toggle should be present
|
|
expect(getByText(/Error Details/)).toBeTruthy();
|
|
|
|
// Click to expand
|
|
fireEvent.press(getByText(/Error Details/));
|
|
|
|
// Error message should be visible
|
|
expect(getByText('Test error')).toBeTruthy();
|
|
});
|
|
|
|
it('should hide error details when showDetails is false', () => {
|
|
const { queryByText } = render(
|
|
<ErrorBoundary showDetails={false}>
|
|
<ThrowingComponent />
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
expect(queryByText(/Error Details/)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('withErrorBoundary HOC', () => {
|
|
it('should wrap component with error boundary', () => {
|
|
const MyComponent = () => <Text>My Component</Text>;
|
|
const WrappedComponent = withErrorBoundary(MyComponent);
|
|
|
|
const { getByText } = render(<WrappedComponent />);
|
|
|
|
expect(getByText('My Component')).toBeTruthy();
|
|
});
|
|
|
|
it('should catch errors from wrapped component', () => {
|
|
const onError = jest.fn();
|
|
const WrappedComponent = withErrorBoundary(ThrowingComponent, { onError });
|
|
|
|
const { getByText } = render(<WrappedComponent />);
|
|
|
|
expect(getByText('Oops! Something went wrong')).toBeTruthy();
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass props to wrapped component', () => {
|
|
interface Props {
|
|
message: string;
|
|
}
|
|
|
|
const MyComponent = ({ message }: Props) => <Text>{message}</Text>;
|
|
const WrappedComponent = withErrorBoundary(MyComponent);
|
|
|
|
const { getByText } = render(<WrappedComponent message="Hello Props" />);
|
|
|
|
expect(getByText('Hello Props')).toBeTruthy();
|
|
});
|
|
|
|
it('should set correct displayName', () => {
|
|
const MyComponent = () => <Text>Test</Text>;
|
|
MyComponent.displayName = 'MyComponent';
|
|
|
|
const WrappedComponent = withErrorBoundary(MyComponent);
|
|
|
|
expect(WrappedComponent.displayName).toBe('withErrorBoundary(MyComponent)');
|
|
});
|
|
});
|