Skip to content
Codeloom
React

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.

·7 min read · By Codeloom
Advanced 12 min read

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.