React Testing Library: Advanced Patterns for Complex UIs
Advanced testing patterns with React Testing Library including custom render wrappers, async flows, portals, context-heavy components, and integration testing.
What you'll learn
- ✓How to build reusable custom render functions with providers
- ✓Testing async flows, loading states, and error boundaries
- ✓Strategies for testing portals, modals, and focus management
- ✓Testing components that depend on context and routing
- ✓Integration testing patterns for multi-component workflows
Prerequisites
None — this post is self-contained.
React Testing Library excels at testing components the way users interact with them. The basics are well-documented: render, query, assert. But real applications have auth contexts, theme providers, routers, modals, complex async flows, and interconnected components. This article covers the patterns that handle that complexity.
Custom render with providers
Most components depend on context providers. Instead of wrapping every test in providers manually, build a custom render function.
// test-utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { AuthProvider } from '@/contexts/AuthContext';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
type CustomRenderOptions = RenderOptions & {
user?: { id: string; name: string; role: string } | null;
route?: string;
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { user = { id: '1', name: 'Test User', role: 'admin' }, route = '/', ...renderOptions } = options;
if (route !== '/') {
window.history.pushState({}, '', route);
}
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider initialUser={user}>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
}
export { screen, waitFor, within } from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
Now every test uses renderWithProviders and gets all the context it needs:
import { renderWithProviders, screen, userEvent } from './test-utils';
import { Dashboard } from './Dashboard';
test('shows admin controls for admin users', async () => {
renderWithProviders(<Dashboard />, {
user: { id: '1', name: 'Admin', role: 'admin' },
});
expect(screen.getByRole('button', { name: /manage users/i })).toBeInTheDocument();
});
test('hides admin controls for regular users', async () => {
renderWithProviders(<Dashboard />, {
user: { id: '2', name: 'Regular', role: 'viewer' },
});
expect(screen.queryByRole('button', { name: /manage users/i })).not.toBeInTheDocument();
});
Testing async flows end to end
Real components load data, show loading states, handle errors, and update after mutations. Test the full cycle.
import { renderWithProviders, screen, waitFor, userEvent } from './test-utils';
import { http, HttpResponse } from 'msw';
import { server } from './mocks/server';
import { TaskList } from './TaskList';
test('loads tasks, adds a new one, and shows it in the list', async () => {
const user = userEvent.setup();
// Mock initial data
server.use(
http.get('/api/tasks', () => {
return HttpResponse.json([
{ id: '1', title: 'Existing task', done: false },
]);
}),
http.post('/api/tasks', async ({ request }) => {
const body = await request.json() as { title: string };
return HttpResponse.json({ id: '2', title: body.title, done: false });
})
);
renderWithProviders(<TaskList />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText('Existing task')).toBeInTheDocument();
});
// Add a new task
const input = screen.getByPlaceholderText(/add a task/i);
await user.type(input, 'New task');
await user.click(screen.getByRole('button', { name: /add/i }));
// New task appears
await waitFor(() => {
expect(screen.getByText('New task')).toBeInTheDocument();
});
});
This tests the full user workflow, not individual functions. The test reads like a user story.
Testing error states
Mock failures to test error boundaries and error messages:
test('shows error message when tasks fail to load', async () => {
server.use(
http.get('/api/tasks', () => {
return HttpResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
})
);
renderWithProviders(<TaskList />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/failed to load tasks/i)).toBeInTheDocument();
});
// Test retry
server.use(
http.get('/api/tasks', () => {
return HttpResponse.json([{ id: '1', title: 'Task', done: false }]);
})
);
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText('Task')).toBeInTheDocument();
});
});
Testing modals and portals
Modals rendered through portals exist outside the component’s DOM subtree. React Testing Library queries the entire document by default, so portal content is findable with standard queries.
test('opens confirmation modal and confirms deletion', async () => {
const user = userEvent.setup();
renderWithProviders(<UserManagement />);
// Click delete on a user row
const deleteButton = screen.getAllByRole('button', { name: /delete/i })[0];
await user.click(deleteButton);
// Modal appears (even though it is a portal)
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
// Confirm deletion
await user.click(screen.getByRole('button', { name: /confirm/i }));
// Modal closes
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
Test focus management for modals separately:
test('traps focus inside modal when open', async () => {
const user = userEvent.setup();
renderWithProviders(<ModalTrigger />);
await user.click(screen.getByRole('button', { name: /open/i }));
const dialog = screen.getByRole('dialog');
const focusableElements = within(dialog).getAllByRole('button');
// First focusable element should have focus
expect(focusableElements[0]).toHaveFocus();
// Tab through all elements, focus should stay in modal
for (const el of focusableElements) {
await user.tab();
}
// Focus wraps back to first element
expect(focusableElements[0]).toHaveFocus();
});
Testing components with routing
For components that read URL parameters or navigate programmatically:
import { renderWithProviders, screen, userEvent } from './test-utils';
import { ProductPage } from './ProductPage';
import { Route, Routes } from 'react-router-dom';
test('displays product based on URL parameter', async () => {
renderWithProviders(
<Routes>
<Route path="/products/:id" element={<ProductPage />} />
</Routes>,
{ route: '/products/123' }
);
await waitFor(() => {
expect(screen.getByText('Product 123')).toBeInTheDocument();
});
});
Testing form validation
Test validation the way users encounter it: fill in fields, submit, and check for error messages.
test('shows validation errors on submit', async () => {
const user = userEvent.setup();
renderWithProviders(<RegistrationForm />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /register/i }));
// Check for validation messages
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
// Fix one error
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /register/i }));
// Email error gone, password error remains
expect(screen.queryByText(/email is required/i)).not.toBeInTheDocument();
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
});
Testing custom hooks
Use renderHook from React Testing Library for testing hooks in isolation:
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';
test('debounces value updates', async () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'initial' } }
);
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
// Value has not changed yet
expect(result.current).toBe('initial');
// Wait for debounce
await waitFor(() => {
expect(result.current).toBe('updated');
});
});
Integration test patterns
Unit tests verify individual components. Integration tests verify that components work together correctly. The boundary between them is the number of components in the render tree.
test('complete checkout flow', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/cart', () =>
HttpResponse.json({
items: [{ id: '1', name: 'Widget', price: 29.99, quantity: 2 }],
})
),
http.post('/api/orders', () =>
HttpResponse.json({ orderId: 'ORD-001', status: 'confirmed' })
)
);
renderWithProviders(<CheckoutPage />);
// Cart summary appears
await waitFor(() => {
expect(screen.getByText('Widget')).toBeInTheDocument();
expect(screen.getByText('$59.98')).toBeInTheDocument();
});
// Fill shipping info
await user.type(screen.getByLabelText(/address/i), '123 Main St');
await user.type(screen.getByLabelText(/city/i), 'Springfield');
// Place order
await user.click(screen.getByRole('button', { name: /place order/i }));
// Confirmation
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText('ORD-001')).toBeInTheDocument();
});
});
This test exercises the cart display, the shipping form, the order submission, and the confirmation page. It catches integration bugs that unit tests miss: props not being passed correctly between components, context not being updated, or navigation not happening after submission.
Guiding principles
Query by role and label, not by test ID or class name. This ensures your tests break when accessibility breaks, which is the right coupling. Use userEvent instead of fireEvent for realistic interactions. Mock at the network level with MSW, not at the module level. Test the user-visible outcome, not the internal implementation. When a test is hard to write, the component API is probably too complex.
Related articles
- React React Testing Library: Complete Testing Guide
Learn to test React components with React Testing Library. Covers rendering, user events, async testing, mocking, and accessibility-driven queries.
- React React Compound Components: Flexible APIs Without Prop Drilling
Build flexible, composable React components using the compound components pattern with Context, implicit state sharing, and controlled inversion.
- React React Error Boundaries in Production: Recovery and Monitoring
Production-grade Error Boundary patterns for React apps including granular placement, retry logic, error reporting, and integration with Suspense and routing.
- React React Server Components: Architecture Patterns That Scale
Practical architecture patterns for React Server Components including composition strategies, data flow boundaries, and migration approaches for production apps.