Skip to main content

Advanced Vitest: Testing Complex React Patterns

Once you've mastered basic Vitest testing, you'll encounter complex React patterns that require advanced techniques: error boundaries that catch child errors, components that suspend during data fetching, context providers with complex state, and portals that render outside the DOM tree. This article teaches you the patterns that professional React developers use to test these scenarios (Vitest documentation, 2026).

Testing Error Boundaries

Error boundaries catch errors thrown by child components. Test that they display fallback UI when children error:

// ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
}

interface State {
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}

static getDerivedStateFromError(error: Error) {
return { error };
}

render() {
if (this.state.error) {
return this.props.fallback?.(this.state.error) || <p>Something went wrong</p>;
}
return this.props.children;
}
}

Test it by throwing an error from a child component:

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from './ErrorBoundary';

// Suppress error log output during test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

function ThrowingComponent() {
throw new Error('Child component failed');
}

describe('ErrorBoundary', () => {
afterEach(() => {
consoleSpy.mockClear();
});

it('renders fallback UI when child errors', () => {
render(
<ErrorBoundary fallback={(err) => <p>Error: {err.message}</p>}>
<ThrowingComponent />
</ErrorBoundary>
);

expect(screen.getByText(/Error: Child component failed/)).toBeInTheDocument();
});

it('renders default fallback when no custom fallback provided', () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);

expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});

it('renders children when no error occurs', () => {
render(
<ErrorBoundary>
<p>Safe content</p>
</ErrorBoundary>
);

expect(screen.getByText('Safe content')).toBeInTheDocument();
});
});

Key pattern: Suppress console.error output during tests (error boundaries log errors) with vi.spyOn(console, 'error').mockImplementation(() => {}).

Testing Suspense and Lazy Components

Test components that suspend (pause rendering) while loading:

// UserProfile.tsx
const UserDetails = lazy(() => import('./UserDetails'));

export function UserProfile({ userId }: { userId: string }) {
return (
<Suspense fallback={<p>Loading profile...</p>}>
<UserDetails userId={userId} />
</Suspense>
);
}

Test the loading state and resolved state:

import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';

describe('UserProfile with Suspense', () => {
it('shows loading fallback initially', () => {
// Simulate the lazy component taking time to load
render(<UserProfile userId="123" />);

// Fallback shows immediately
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
});

it('renders user details after suspending component loads', async () => {
render(<UserProfile userId="123" />);

// Wait for component to render (Suspense resolves)
const userElement = await screen.findByText(/User: /);
expect(userElement).toBeInTheDocument();
});
});

Vitest automatically handles Suspense boundaries in tests (with jsdom environment).

Testing Context Providers with Complex State

Test components that consume context from providers:

// ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({
theme: 'light',
toggleTheme: () => {},
});

export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');

return (
<ThemeContext.Provider value={{
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
}}>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
return useContext(ThemeContext);
}

Test consumer components with the provider wrapper:

// Card.tsx
import { useTheme } from './ThemeContext';

export function Card() {
const { theme } = useTheme();
return <div className={`card card-${theme}`}>Content</div>;
}

// Card.test.tsx
describe('Card with ThemeProvider', () => {
it('renders with light theme by default', () => {
render(
<ThemeProvider>
<Card />
</ThemeProvider>
);

expect(screen.getByText('Content')).toHaveClass('card-light');
});

it('applies dark theme when toggled', async () => {
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<>
<Card />
<button onClick={toggleTheme}>Toggle to {theme === 'light' ? 'dark' : 'light'}</button>
</>
);
}

render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);

// Initially light
expect(screen.getByText('Content')).toHaveClass('card-light');

// Click toggle
const toggleBtn = screen.getByRole('button');
await userEvent.click(toggleBtn);

// Now dark
expect(screen.getByText('Content')).toHaveClass('card-dark');
});
});

Pattern: Wrap tested components in the provider. For testing hooks directly, use renderHook with the wrapper option.

Testing Portals

Portals render content outside the component tree (typically document.body). Test that portals render:

// Modal.tsx
import { createPortal } from 'react-dom';

export function Modal({ isOpen, children, onClose }: Props) {
if (!isOpen) return null;

return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}

Test it by checking the portal renders outside the main tree:

describe('Modal', () => {
it('renders in a portal when open', () => {
const { container } = render(
<div>
<p>Main content</p>
<Modal isOpen>
<p>Modal content</p>
</Modal>
</div>
);

// Modal content is not in the div
expect(container.querySelector('.modal-content')).not.toBeInTheDocument();

// But it IS in the document (body)
expect(document.body.querySelector('.modal-content')).toBeInTheDocument();

// We can still query it globally
expect(screen.getByText('Modal content')).toBeInTheDocument();
});

it('closes modal on overlay click', async () => {
const handleClose = vi.fn();
render(
<Modal isOpen onClose={handleClose}>
<p>Modal content</p>
</Modal>
);

const overlay = document.querySelector('.modal-overlay');
await userEvent.click(overlay!);

expect(handleClose).toHaveBeenCalled();
});

it('does not close when clicking modal content', async () => {
const handleClose = vi.fn();
render(
<Modal isOpen onClose={handleClose}>
<button>Action</button>
</Modal>
);

const content = document.querySelector('.modal-content');
await userEvent.click(content!);

expect(handleClose).not.toHaveBeenCalled();
});
});

Key pattern: screen.getByText() queries the entire DOM (including portals) by default. You can also use document.querySelector() to verify portals exist in document.body.

Testing Render Props and Function Children

Test components that accept render functions as children:

// Async.tsx
interface AsyncProps {
promise: Promise<any>;
children: (state: { loading: boolean; data?: any; error?: Error }) => ReactNode;
}

export function Async({ promise, children }: AsyncProps) {
const [state, setState] = useState({ loading: true, data: null, error: null });

useEffect(() => {
promise
.then((data) => setState({ loading: false, data }))
.catch((error) => setState({ loading: false, error }));
}, [promise]);

return children(state);
}

Test by providing a render function:

describe('Async render props', () => {
it('calls children with loading state initially', () => {
const renderFn = vi.fn(() => <p>Test</p>);
const neverResolves = new Promise(() => {});

render(<Async promise={neverResolves}>{renderFn}</Async>);

expect(renderFn).toHaveBeenCalledWith(
expect.objectContaining({ loading: true })
);
});

it('calls children with data when promise resolves', async () => {
const renderFn = vi.fn((state) =>
state.data ? <p>Data: {state.data.name}</p> : <p>Loading</p>
);

const promise = Promise.resolve({ name: 'Alice' });
render(<Async promise={promise}>{renderFn}</Async>);

await waitFor(() => {
expect(renderFn).toHaveBeenCalledWith(
expect.objectContaining({
loading: false,
data: { name: 'Alice' },
})
);
});
});
});

Testing Custom Hooks with Context

Test hooks that depend on context:

// useUser.ts
export function useUser() {
const { userId } = useContext(UserContext);
const [user, setUser] = useState(null);

useEffect(() => {
if (userId) {
fetchUser(userId).then(setUser);
}
}, [userId]);

return user;
}

Test with a provider wrapper in renderHook:

describe('useUser with context', () => {
it('fetches user data from context userId', async () => {
const mockFetchUser = vi.fn(() => Promise.resolve({ id: '123', name: 'Alice' }));
vi.mock('../api', () => ({ fetchUser: mockFetchUser }));

const wrapper = ({ children }: { children: ReactNode }) => (
<UserProvider userId="123">{children}</UserProvider>
);

const { result } = renderHook(() => useUser(), { wrapper });

await waitFor(() => {
expect(result.current).toEqual({ id: '123', name: 'Alice' });
});
});
});

Testing Web APIs (localStorage, sessionStorage, etc.)

Mock web APIs for deterministic tests:

it('persists theme to localStorage', async () => {
const localStorageMock = new Map();
vi.stubGlobal('localStorage', {
getItem: (key) => localStorageMock.get(key),
setItem: (key, val) => localStorageMock.set(key, val),
});

render(
<ThemeProvider>
<button onClick={() => setTheme('dark')}>Toggle</button>
<Card />
</ThemeProvider>
);

await userEvent.click(screen.getByRole('button'));

expect(localStorageMock.get('theme')).toBe('dark');
});

Testing Component Composition Chains

Test deeply nested component hierarchies:

describe('Component composition', () => {
it('renders nested providers and components', () => {
render(
<AuthProvider>
<ThemeProvider>
<Router>
<Layout>
<Page>
<Card />
</Page>
</Layout>
</Router>
</ThemeProvider>
</AuthProvider>
);

expect(screen.getByText('Card content')).toBeInTheDocument();
});
});

Key Takeaways

  • Test error boundaries by throwing errors from child components; suppress console.error with vi.spyOn().
  • Test Suspense by using await screen.findByText() or waitFor() for lazy-loaded components.
  • Test context consumers by wrapping them in the provider.
  • Test hooks with context using renderHook with a wrapper prop.
  • Test portals by querying document.body or using screen (which queries the full DOM).
  • Test render props by providing a mock function and asserting the state passed to it.
  • Mock Web APIs (localStorage, fetch, etc.) with vi.stubGlobal() for deterministic tests.

Frequently Asked Questions

How do I test a component that uses useLayoutEffect?

useLayoutEffect is synchronous. In tests, treat it like useEffect but call render directly (don't await). Layout effects run before paint, so they're already complete by the time you assert.

How do I test forwardRef components?

Use createRef() or useRef() and pass the ref to the component:

it('forwards ref to input', () => {
const ref = createRef<HTMLInputElement>();
render(<TextInput ref={ref} />);

expect(ref.current).toBeInstanceOf(HTMLInputElement);
expect(ref.current?.type).toBe('text');
});

How do I test components that use IntersectionObserver?

Mock IntersectionObserver:

vi.stubGlobal('IntersectionObserver', class {
constructor(callback: IntersectionObserverCallback) {
callback(
[{ isIntersecting: true, target: null } as any],
this as any
);
}
observe() {}
disconnect() {}
});

How do I test a component with a ref to a third-party library?

Test through the component's behavior, not the ref. If your component uses a D3 chart ref, test that the chart renders correctly (via assertions), not the ref itself.

How do I test code that runs in useLayoutEffect but shouldn't in tests?

Wrap the code in if (typeof window !== 'undefined') to skip in Node.js, or use vi.stubGlobal('window', undefined) to simulate server-side rendering in tests.

Further Reading