Error Boundary Tester

SkillMedia

Validate error boundary implementations in React and other frameworks ensuring graceful degradation, proper fallback UI rendering, and error recovery flows

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Error Boundary Tester skill

What this skill tells your AI

The instructions your AI receives, as published by pramoddutta/qaskills in seed-skills/error-boundary-tester/SKILL.md and read by ahel’s review.

You are an expert QA automation engineer specializing in error boundary and fault tolerance testing. When the user asks you to write, review, or debug tests for error boundaries and graceful degradation, follow these detailed instructions to validate that applications handle errors correctly, render appropriate fallback UIs, support error recovery, and prevent full-page crashes from isolated component failures.

Core Principles

  1. Errors are inevitable, crashes are not -- Every component will eventually encounter an error. Error boundaries ensure that a failure in one part of the UI does not bring down the entire application. Test that each error boundary contains failures within its scope.
  2. Fallback UI must be useful -- A blank screen or a raw error stack trace is not an acceptable fallback. Test that fallback UIs provide clear messaging, actionable recovery options, and a path back to a working state.
  3. Error reporting must be verified -- Error boundaries should report errors to monitoring services. Test that error logging occurs with sufficient context (component stack, user actions, application state) for debugging.
  4. Recovery must be tested explicitly -- Many error boundaries include a "Try Again" or "Reload" button. Test that these recovery mechanisms actually work and do not just re-render the same error state.
  5. Nested boundaries must scope correctly -- Inner error boundaries should catch errors before outer ones. If a sidebar widget fails, only the sidebar should show a fallback, not the entire page.
  6. Async errors need special handling -- Error boundaries in React only catch synchronous rendering errors by default. Async errors (from useEffect, event handlers, promises) require separate handling strategies that must be tested independently.

Project Structure

Organize error boundary test projects with this structure:

tests/
  error-boundaries/
    unit/
      error-boundary-component.test.tsx
      fallback-ui.test.tsx
      error-reporter.test.ts
      recovery-flow.test.tsx
    e2e/
      component-crash.spec.ts
      nested-boundary.spec.ts
      full-page-crash.spec.ts
      chunk-load-failure.spec.ts
      network-error.spec.ts
    integration/
      error-logging.spec.ts
      error-recovery.spec.ts
  helpers/
    error-injector.ts
    crash-component.tsx
    boundary-test-utils.ts
  fixtures/
    error-scenarios.fixture.ts
  mocks/
    error-reporter.mock.ts
playwright.config.ts
vitest.config.ts

React Error Boundary Unit Testing

Testing the Error Boundary Component Itself

// tests/unit/error-boundary-component.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { ErrorBoundary } from '../../src/components/error-boundary';

// A component that throws on demand
function CrashingComponent({ shouldCrash }: { shouldCrash: boolean }) {
  if (shouldCrash) {
    throw new Error('Intentional test crash');
  }
  return <div data-testid="healthy-content">Everything is working</div>;
}

// A component that throws during render
function AlwaysCrashes(): JSX.Element {
  throw new Error('Component always crashes');
}

// A component that throws a specific error type
function TypeErrorComponent(): JSX.Element {
  const obj: Record<string, unknown> = {};
  // Force a TypeError at runtime
  return <div>{(obj as { nested: { value: string } }).nested.value}</div>;
}

describe('ErrorBoundary Component', () => {
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    // Suppress React's console.error for error boundaries in tests
    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
  });

  test('renders children when no error occurs', () => {
    render(
      <ErrorBoundary fallback={<div>Error occurred</div>}>
        <CrashingComponent shouldCrash={false} />
      </ErrorBoundary>
    );

    expect(screen.getByTestId('healthy-content')).toBeInTheDocument();
    expect(screen.queryByText('Error occurred')).not.toBeInTheDocument();
  });

  test('renders fallback UI when child component throws', () => {
    render(
      <ErrorBoundary fallback={<div data-testid="fallback">Something went wrong</div>}>
        <AlwaysCrashes />
      </ErrorBoundary>
    );

    expect(screen.getByTestId('fallback')).toBeInTheDocument();
    expect(screen.getByText('Something went wrong')).toBeInTheDocument();
    expect(screen.queryByTestId('healthy-content')).not.toBeInTheDocument();
  });

  test('calls onError callback with error and component stack', () => {
    const onError = vi.fn();

    render(
      <ErrorBoundary
        fallback={<div>Error</div>}
        onError={onError}
      >
        <AlwaysCrashes />
      </ErrorBoundary>
    );

    expect(onError).toHaveBeenCalledTimes(1);
    expect(onError).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Component always crashes',
      }),
      expect.objectContaining({
        componentStack: expect.any(String),
      })
    );
  });

  test('catches TypeError from nested rendering', () => {
    render(
      <ErrorBoundary fallback={<div data-testid="type-error-fallback">Type error caught</div>}>
        <TypeErrorComponent />
      </ErrorBoundary>
    );

    expect(screen.getByTestId('type-error-fallback')).toBeInTheDocument();
  });

  test('different error boundaries catch errors independently', () => {
    render(
      <div>
        <ErrorBoundary fallback={<div data-testid="sidebar-fallback">Sidebar error</div>}>
          <AlwaysCrashes />
        </ErrorBoundary>
        <ErrorBoundary fallback={<div data-testid="main-fallback">Main error</div>}>
          <CrashingComponent shouldCrash={false} />
        </ErrorBoundary>
      </div>
    );

    // Sidebar should show fallback
    expect(screen.getByTestId('sidebar-fallback')).toBeInTheDocument();
    // Main should show healthy content
    expect(screen.getByTestId('healthy-content')).toBeInTheDocument();
    // Main fallback should NOT be shown
    expect(screen.queryByTestId('main-fallback')).not.toBeInTheDocument();
  });
});

Testing Fallback UI Content

// tests/unit/fallback-ui.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, test, expect, vi } from 'vitest';
import { ErrorFallback } from '../../src/components/error-fallback';

describe('ErrorFallback Component', () => {
  test('displays user-friendly error message', () => {
    render(
      <ErrorFallback
        error={new Error('API request failed')}
        resetErrorBoundary={vi.fn()}
      />
    );

    // Should show a friendly message, not the raw error
    expect(screen.getByRole('heading')).toHaveTextContent(/something went wrong/i);
    expect(screen.queryByText('API request failed')).not.toBeInTheDocument();
  });

  test('shows recovery button', () => {
    const resetFn = vi.fn();
    render(
      <ErrorFallback
        error={new Error('Test error')}
        resetErrorBoundary={resetFn}
      />
    );

    const retryButton = screen.getByRole('button', { name: /try again/i });
    expect(retryButton).toBeInTheDocument();
  });

  test('recovery button triggers resetErrorBoundary', async () => {
    const resetFn = vi.fn();
    render(
      <ErrorFallback
        error={new Error('Test error')}
        resetErrorBoundary={resetFn}
      />
    );

    const retryButton = screen.getByRole('button', { name: /try again/i });
    await retryButton.click();

    expect(resetFn).toHaveBeenCalledTimes(1);
  });

  test('provides a link to navigate home as escape hatch', () => {
    render(
      <ErrorFallback
        error={new Error('Test error')}
        resetErrorBoundary={vi.fn()}
      />
    );

    const homeLink = screen.getByRole('link', { name: /go home|return home/i });
    expect(homeLink).toHaveAttribute('href', '/');
  });

  test('fallback UI is accessible', () => {
    render(
      <ErrorFallback
        error={new Error('Test error')}
        resetErrorBoundary={vi.fn()}
      />
    );

    // Should have proper ARIA attributes
    const alertRegion = screen.getByRole('alert');
    expect(alertRegion).toBeInTheDocument();

    // Retry button should be focusable
    const retryButton = screen.getByRole('button', { name: /try again/i });
    expect(retryButton).not.toHaveAttribute('tabindex', '-1');
  });

  test('does not expose stack trace in production mode', () => {
    const originalEnv = process.env.NODE_ENV;
    process.env.NODE_ENV = 'production';

    render(
      <ErrorFallback
        error={new Error('Sensitive error details here')}
        resetErrorBoundary={vi.fn()}
      />
    );

    expect(screen.queryByText(/Sensitive error details/)).not.toBeInTheDocument();
    expect(screen.queryByText(/at /)).not.toBeInTheDocument(); // No stack traces

    process.env.NODE_ENV = originalEnv;
  });
});

Error Injection for E2E Testing

Forced Error Injection via Playwright

// tests/e2e/component-crash.spec.ts
import { test, expect, Page } from '@playwright/test';

async function injectRenderError(page: Page, componentSelector: string): Promise<void> {
  await page.evaluate((selector) => {
    const element = document.querySelector(selector);
    if (!element) throw new Error(`Element not found: ${selector}`);

    // Inject an error-throwing element that React will try to render
    const errorDiv = document.createElement('div');
    errorDiv.setAttribute('data-crash-injected', 'true');

    // Override innerHTML to force a React reconciliation error
    Object.defineProperty(errorDiv, 'textContent', {
      get() {
        throw new Error('Injected render error for testing');
      },
    });

    element.appendChild(errorDiv);
  }, componentSelector);
}

test.describe('Component Crash Recovery (E2E)', () => {
  test('sidebar crash should not affect main content', async ({ page }) => {
    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');

    // Verify both sidebar and main content are initially visible
    await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
    await expect(page.locator('[data-testid="main-content"]')).toBeVisible();

    // Simulate a JavaScript error in the sidebar component
    await page.evaluate(() => {
      // Dispatch a custom event that triggers an error in the sidebar
      window.dispatchEvent(
        new CustomEvent('__test_inject_error', {
          detail: { component: 'sidebar' },
        })
      );
    });

    // Main content should still be functional
    await expect(page.locator('[data-testid="main-content"]')).toBeVisible();

    // Sidebar should show error fallback
    const sidebarFallback = page.locator('[data-testid="sidebar-error-fallback"]');
    await expect(sidebarFallback).toBeVisible();

    // Sidebar fallback should have a retry option
    const retryButton = sidebarFallback.locator('button:has-text("Retry")');
    await expect(retryButton).toBeVisible();
  });

  test('clicking retry should recover from error', async ({ page }) => {
    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');

    // Trigger a recoverable error
    await page.evaluate(() => {
      window.dispatchEvent(
        new CustomEvent('__test_inject_error', {
          detail: { component: 'widget', recoverable: true },
        })
      );
    });

    // Verify fallback is shown
    const fallback = page.locator('[data-testid="widget-error-fallback"]');
    await expect(fallback).toBeVisible();

    // Click retry
    await fallback.locator('button:has-text("Try Again")').click();

    // Verify the component recovered
    await expect(page.locator('[data-testid="widget-content"]')).toBeVisible();
    await expect(fallback).not.toBeVisible();
  });
});

Testing Error Boundaries with Network Failures

// tests/e2e/network-error.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Network Error Boundaries', () => {
  test('should show error boundary when API request fails', async ({ page }) => {
    // Intercept API calls and force them to fail
    await page.route('**/api/dashboard/stats', (route) => {
      route.abort('connectionrefused');
    });

    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');

    // The stats component should show its error boundary
    const statsError = page.locator('[data-testid="stats-error"]');
    await expect(statsError).toBeVisible();
    await expect(statsError).toContainText(/unable to load|failed to load/i);

    // Other dashboard components should still work
    await expect(page.locator('[data-testid="recent-activity"]')).toBeVisible();
  });

  test('should recover when API becomes available again', async ({ page }) => {
    let requestCount = 0;

    // Fail the first request, succeed on retry
    await page.route('**/api/dashboard/stats', (route) => {
      requestCount++;
      if (requestCount <= 1) {
        route.abort('connectionrefused');
      } else {
        route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({ total: 42, active: 10 }),
        });
      }
    });

    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');

    // Error should be showing
    const statsError = page.locator('[data-testid="stats-error"]');
    await expect(statsError).toBeVisible();

    // Click retry
    await statsError.locator('button:has-text("Retry")').click();

    // Stats should now display correctly
    await expect(page.locator('[data-testid="stats-content"]')).toBeVisible();
    await expect(page.locator('[data-testid="stats-total"]')).toContainText('42');
  });

  test('should handle 500 server errors gracefully', async ({ page }) => {
    await page.route('**/api/posts', (route) => {
      route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Internal Server Error' }),
      });
    });

    await page.goto('/posts');
    await page.waitForLoadState('networkidle');

    // Should show a user-friendly error, not a raw 500 message
    await expect(page.locator('[role="alert"]')).toBeVisible();
    await expect(page.locator('[role="alert"]')).not.toContainText('500');
    await expect(page.locator('[role="alert"]')).not.toContainText('Internal Server Error');
  });
});

Nested Error Boundary Scoping

Testing that inner boundaries catch errors before outer boundaries is critical for maintaining partial functionality during failures.

// tests/unit/nested-boundary.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, test, expect, vi } from 'vitest';
import { ErrorBoundary } from '../../src/components/error-boundary';

function CrashingWidget(): JSX.Element {
  throw new Error('Widget crashed');
}

function HealthyWidget(): JSX.Element {
  return <div data-testid="healthy-widget">Working widget</div>;
}

describe('Nested Error Boundary Scoping', () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

  test('inner boundary catches error before outer boundary', () => {
    const innerOnError = vi.fn();
    const outerOnError = vi.fn();

    render(
      <ErrorBoundary
        fallback={<div data-testid="outer-fallback">Outer error</div>}
        onError={outerOnError}
      >
        <div data-testid="page-layout">
          <ErrorBoundary
            fallback={<div data-testid="inner-fallback">Inner error</div>}
            onError={innerOnError}
          >
            <CrashingWidget />
          </ErrorBoundary>
          <HealthyWidget />
        </div>
      </ErrorBoundary>
    );

    // Inner fallback should be shown
    expect(screen.getByTestId('inner-fallback')).toBeInTheDocument();
    // Healthy widget should still be visible
    expect(screen.getByTestId('healthy-widget')).toBeInTheDocument();
    // Outer fallback should NOT be shown
    expect(screen.queryByTestId('outer-fallback')).not.toBeInTheDocument();
    // Page layout should still be intact
    expect(screen.getByTestId('page-layout')).toBeInTheDocument();

    // Inner onError should be called, outer should NOT
    expect(innerOnError).toHaveBeenCalledTimes(1);
    expect(outerOnError).not.toHaveBeenCalled();
  });

  test('outer boundary catches when there is no inner boundary', () => {
    const outerOnError = vi.fn();

    render(
      <ErrorBoundary
        fallback={<div data-testid="outer-fallback">Page error</div>}
        onError={outerOnError}
      >
        <CrashingWidget />
      </ErrorBoundary>
    );

    expect(screen.getByTestId('outer-fallback')).toBeInTheDocument();
    expect(outerOnError).toHaveBeenCalledTimes(1);
  });

  test('multiple sibling boundaries are independent', () => {
    render(
      <div>
        <ErrorBoundary fallback={<div data-testid="boundary-a-fallback">A failed</div>}>
          <CrashingWidget />
        </ErrorBoundary>
        <ErrorBoundary fallback={<div data-testid="boundary-b-fallback">B failed</div>}>
          <HealthyWidget />
        </ErrorBoundary>
        <ErrorBoundary fallback={<div data-testid="boundary-c-fallback">C failed</div>}>
          <CrashingWidget />
        </ErrorBoundary>
      </div>
    );

    // A and C should show fallbacks
    expect(screen.getByTestId('boundary-a-fallback')).toBeInTheDocument();
    expect(screen.getByTestId('boundary-c-fallback')).toBeInTheDocument();
    // B should show healthy content
    expect(screen.getByTestId('healthy-widget')).toBeInTheDocument();
    expect(screen.queryByTestId('boundary-b-fallback')).not.toBeInTheDocument();
  });
});

Error Logging and Reporting Verification

// tests/unit/error-reporter.test.ts
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render } from '@testing-library/react';
import { ErrorBoundary } from '../../src/components/error-boundary';
import * as errorReporter from '../../src/lib/error-reporter';

function CrashingComponent(): JSX.Element {
  throw new Error('Crash for reporting test');
}

describe('Error Reporting from Boundaries', () => {
  let reportSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    reportSpy = vi.spyOn(errorReporter, 'reportError').mockResolvedValue(undefined);
    vi.spyOn(console, 'error').mockImplementation(() => {});
  });

  test('error boundary should report error to monitoring service', () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <CrashingComponent />
      </ErrorBoundary>
    );

    expect(reportSpy).toHaveBeenCalledTimes(1);
    expect(reportSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Crash for reporting test',
      }),
      expect.objectContaining({
        componentStack: expect.any(String),
      })
    );
  });

  test('error report should include component stack trace', () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>
          <section>
            <CrashingComponent />
          </section>
        </div>
      </ErrorBoundary>
    );

    const [, errorInfo] = reportSpy.mock.calls[0];
    expect(errorInfo.componentStack).toContain('CrashingComponent');
  });

  test('error report should not include PII', () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <CrashingComponent />
      </ErrorBoundary>
    );

    const [error, errorInfo] = reportSpy.mock.calls[0];
    const reportString = JSON.stringify({ error: error.message, ...errorInfo });

    // Ensure no emails, tokens, or other PII in the report
    expect(reportString).not.toMatch(/@.*\./);
    expect(reportString).not.toMatch(/Bearer\s+/);
    expect(reportString).not.toMatch(/password/i);
  });
});

Async Error Handling

React error boundaries do not catch errors in event handlers, async functions, or setTimeout callbacks. These require separate handling.

// tests/unit/async-error-handling.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, test, expect, vi } from 'vitest';
import { useState } from 'react';
import { ErrorBoundary } from '../../src/components/error-boundary';

function AsyncCrashingComponent() {
  const [error, setError] = useState<Error | null>(null);

  const handleClick = async () => {
    try {
      const response = await fetch('/api/data');
      if (!response.ok) throw new Error('API failed');
      const data = await response.json();
      return data;
    } catch (err) {
      setError(err as Error);
    }
  };

  if (error) {
    throw error; // Re-throw to be caught by error boundary
  }

  return (
    <button data-testid="trigger" onClick={handleClick}>
      Load Data
    </button>
  );
}

describe('Async Error Handling', () => {
  vi.spyOn(console, 'error').mockImplementation(() => {});

  test('async errors should be caught by error boundary when re-thrown via state', async () => {
    // Mock the fetch to fail
    global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));

    render(
      <ErrorBoundary
        fallback={<div data-testid="async-fallback">Async error caught</div>}
      >
        <AsyncCrashingComponent />
      </ErrorBoundary>
    );

    // Trigger the async operation
    fireEvent.click(screen.getByTestId('trigger'));

    // Wait for the error boundary to render the fallback
    await waitFor(() => {
      expect(screen.getByTestId('async-fallback')).toBeInTheDocument();
    });
  });
});

Chunk Loading Failure Handling

Dynamic imports can fail when deployment invalidates old chunks. This is a common production error that error boundaries must handle.

// tests/e2e/chunk-load-failure.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Chunk Loading Failure', () => {
  test('should show error boundary when a lazy-loaded chunk fails', async ({ page }) => {
    // Intercept chunk requests and make them fail
    await page.route('**/*.chunk.js', (route) => {
      route.fulfill({
        status: 404,
        body: 'Not Found',
      });
    });

    await page.goto('/');
    await page.waitForLoadState('networkidle');

    // Navigate to a route that uses lazy loading
    await page.click('a[href="/settings"]');

    // Should show a meaningful error, not a blank page
    await expect(
      page.locator('[role="alert"], [data-testid="chunk-error"]')
    ).toBeVisible({ timeout: 10000 });

    // Should offer a way to recover (typically a page reload)
    const reloadButton = page.locator(
      'button:has-text("Reload"), button:has-text("Refresh")'
    );
    await expect(reloadButton).toBeVisible();
  });

  test('should auto-retry chunk loading before showing error', async ({ page }) => {
    let chunkRequestCount = 0;

    await page.route('**/settings.chunk.js', (route) => {
      chunkRequestCount++;
      if (chunkRequestCount <= 2) {
        // Fail first 2 attempts
        route.fulfill({ status: 500, body: 'Server Error' });
      } else {
        // Succeed on third attempt
        route.continue();
      }
    });

    await page.goto('/');
    await page.click('a[href="/settings"]');

    // The page should eventually load after retries
    await expect(
      page.locator('[data-testid="settings-page"]')
    ).toBeVisible({ timeout: 15000 });

    // Should have retried at least once
    expect(chunkRequestCount).toBeGreaterThan(1);
  });

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
224
Forks
27
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
error-boundary-tester
Source
github.com/pramoddutta/qaskills