← Back to DevBytes

Testing Radix UI Components: From Unit to E2E Tests

Testing Radix UI Components: From Unit to E2E Tests

Radix UI is a popular headless component library for React that provides accessible, unstyled primitives. Because Radix handles complex behaviors like focus management, keyboard navigation, and ARIA attributes, testing these components properly is essential to ensure your application remains robust and accessible. This tutorial walks you through a complete testing strategy — from isolated unit tests to full end-to-end (E2E) tests — so you can confidently ship Radix-powered interfaces.

Why Testing Radix UI Matters

Radix components are designed to be accessible out of the box, but they are also highly composable. This means the way you wire them together can introduce subtle bugs: a missing Dialog.Description can break screen reader announcements, an uncontrolled Switch can lose its state, or a DropdownMenu can trap focus incorrectly. Testing helps you catch these issues early.

Testing Strategy Overview

A solid testing strategy for Radix components follows the testing pyramid. Unit tests validate individual component logic and rendering. Integration tests verify composed Radix primitives behave together. End-to-end tests simulate real user journeys through the browser. Each layer has a purpose and complements the others.

Setting Up Your Testing Environment

For unit and integration tests, we will use Vitest (or Jest) with React Testing Library and @testing-library/user-event. For E2E tests, we will use Playwright. Start by installing the necessary dependencies:

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
npm install -D @playwright/test

Create a Vitest setup file to register custom matchers like toBeInTheDocument:

// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

Configure Vitest in your vite.config.ts:

/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './vitest.setup.ts',
  },
});

Unit Testing Radix Components

Unit tests focus on a single component in isolation. With Radix, you typically test a wrapper component you built around a Radix primitive. The key principle is to test behavior the way a user would interact with it — by querying accessible roles and simulating real events — rather than testing implementation details.

Example: Testing a Custom Switch Component

Suppose you built a custom toggle switch on top of @radix-ui/react-switch:

// components/ToggleSwitch.tsx
import * as Switch from '@radix-ui/react-switch';

interface ToggleSwitchProps {
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;
  label: string;
}

export function ToggleSwitch({ checked, onCheckedChange, label }: ToggleSwitchProps) {
  return (
    <label className="flex items-center gap-2">
      <span>{label}</span>
      <Switch.Root
        checked={checked}
        onCheckedChange={onCheckedChange}
        role="switch"
        aria-label={label}
      >
        <Switch.Thumb />
      </Switch.Root>
    </label>
  );
}

Now write a unit test that verifies the switch renders, reflects its state, and fires the change callback:

// components/ToggleSwitch.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ToggleSwitch } from './ToggleSwitch';

describe('ToggleSwitch', () => {
  it('renders with the provided label', () => {
    render(<ToggleSwitch checked={false} onCheckedChange={() => {}} label="Dark mode" />);
    expect(screen.getByText('Dark mode')).toBeInTheDocument();
  });

  it('reflects the checked state via aria-checked', () => {
    render(<ToggleSwitch checked={true} onCheckedChange={() => {}} label="Dark mode" />);
    expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
  });

  it('calls onCheckedChange when clicked', async () => {
    const user = userEvent.setup();
    const onCheckedChange = vi.fn();
    render(<ToggleSwitch checked={false} onCheckedChange={onCheckedChange} label="Dark mode" />);

    await user.click(screen.getByRole('switch'));
    expect(onCheckedChange).toHaveBeenCalledWith(true);
  });

  it('toggles with the Space key', async () => {
    const user = userEvent.setup();
    const onCheckedChange = vi.fn();
    render(<ToggleSwitch checked={false} onCheckedChange={onCheckedChange} label="Dark mode" />);

    const sw = screen.getByRole('switch');
    sw.focus();
    await user.keyboard(' ');
    expect(onCheckedChange).toHaveBeenCalledWith(true);
  });
});

Notice that we never query by class name or test internal state. We query by role and label, which mirrors how assistive technology perceives the component. This makes tests resilient to refactors and ensures accessibility is verified as a side effect.

Integration Testing Composed Radix Primitives

Radix components like Dialog, Popover, DropdownMenu, and Tabs involve multiple parts working together. Integration tests verify these compositions behave correctly, including portal rendering, focus management, and outside-click dismissal.

Example: Testing a Dialog Component

Consider a dialog built with Radix primitives:

// components/ConfirmDialog.tsx
import * as Dialog from '@radix-ui/react-dialog';

interface ConfirmDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onConfirm: () => void;
  title: string;
  description: string;
}

export function ConfirmDialog({ open, onOpenChange, onConfirm, title, description }: ConfirmDialogProps) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>{title}</Dialog.Title>
          <Dialog.Description>{description}</Dialog.Description>
          <div className="flex gap-2">
            <button onClick={() => onOpenChange(false)}>Cancel</button>
            <button onClick={onConfirm}>Confirm</button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Write an integration test that covers opening, confirming, and closing via keyboard:

// components/ConfirmDialog.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfirmDialog } from './ConfirmDialog';

describe('ConfirmDialog', () => {
  it('renders title and description when open', () => {
    render(
      <ConfirmDialog
        open={true}
        onOpenChange={() => {}}
        onConfirm={() => {}}
        title="Delete item"
        description="This action cannot be undone."
      />
    );
    expect(screen.getByRole('heading', { name: 'Delete item' })).toBeInTheDocument();
    expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
  });

  it('calls onConfirm when the Confirm button is clicked', async () => {
    const user = userEvent.setup();
    const onConfirm = vi.fn();
    const onOpenChange = vi.fn();
    render(
      <ConfirmDialog
        open={true}
        onOpenChange={onOpenChange}
        onConfirm={onConfirm}
        title="Delete item"
        description="This action cannot be undone."
      />
    );

    await user.click(screen.getByRole('button', { name: 'Confirm' }));
    expect(onConfirm).toHaveBeenCalledTimes(1);
  });

  it('closes when the Escape key is pressed', async () => {
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    render(
      <ConfirmDialog
        open={true}
        onOpenChange={onOpenChange}
        onConfirm={() => {}}
        title="Delete item"
        description="This action cannot be undone."
      />
    );

    await user.keyboard('{Escape}');
    expect(onOpenChange).toHaveBeenCalledWith(false);
  });

  it('traps focus inside the dialog', async () => {
    const user = userEvent.setup();
    render(
      <ConfirmDialog
        open={true}
        onOpenChange={() => {}}
        onConfirm={() => {}}
        title="Delete item"
        description="This action cannot be undone."
      />
    );

    const cancelBtn = screen.getByRole('button', { name: 'Cancel' });
    const confirmBtn = screen.getByRole('button', { name: 'Confirm' });

    // Focus should start on the first focusable element
    expect(cancelBtn).toHaveFocus();

    await user.tab();
    expect(confirmBtn).toHaveFocus();

    // Tabbing past the last element wraps back to the first
    await user.tab();
    expect(cancelBtn).toHaveFocus();
  });
});

Because Radix renders portals into document.body, React Testing Library's screen queries still find them since they search the entire document. This is why you rarely need to worry about portal containers in your tests.

Testing DropdownMenu Interactions

Dropdown menus involve triggers, content, items, and keyboard navigation. Here is an integration test for a menu with multiple items:

// components/UserMenu.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserMenu } from './UserMenu';

describe('UserMenu', () => {
  it('opens on trigger click and shows menu items', async () => {
    const user = userEvent.setup();
    render(<UserMenu onLogout={() => {}} onProfile={() => {}} />);

    await user.click(screen.getByRole('button', { name: /open menu/i }));
    expect(screen.getByRole('menu')).toBeInTheDocument();
    expect(screen.getByRole('menuitem', { name: /profile/i })).toBeInTheDocument();
    expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument();
  });

  it('navigates items with arrow keys and selects with Enter', async () => {
    const user = userEvent.setup();
    const onProfile = vi.fn();
    render(<UserMenu onLogout={() => {}} onProfile={onProfile} />);

    await user.click(screen.getByRole('button', { name: /open menu/i }));
    const profileItem = screen.getByRole('menuitem', { name: /profile/i });

    await user.keyboard('{ArrowDown}');
    expect(profileItem).toHaveFocus();

    await user.keyboard('{Enter}');
    expect(onProfile).toHaveBeenCalledTimes(1);
  });
});

End-to-End Testing with Playwright

While unit and integration tests run in a simulated DOM, E2E tests run in a real browser. This is critical for Radix components because real browsers handle focus, portals, animations, and event bubbling differently than jsdom. Playwright is an excellent choice for E2E testing Radix UI.

Setting Up Playwright

Initialize Playwright with the CLI:

npx playwright init

This creates a playwright.config.ts file and an e2e directory. Configure the config to point at your dev server:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

Example: E2E Test for a Settings Page

Imagine a settings page with a Radix Tabs component, a Switch for notifications, and a Dialog for confirming changes. An E2E test simulates a user navigating the full flow:

// e2e/settings.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Settings page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/settings');
  });

  test('switches tabs and toggles notifications', async ({ page }) => {
    // Verify the Notifications tab is not active initially
    const notificationsTab = page.getByRole('tab', { name: /notifications/i });
    const profileTab = page.getByRole('tab', { name: /profile/i });

    await expect(profileTab).toHaveAttribute('aria-selected', 'true');

    // Switch to Notifications tab
    await notificationsTab.click();
    await expect(notificationsTab).toHaveAttribute('aria-selected', 'true');

    // Toggle the email notifications switch
    const emailSwitch = page.getByRole('switch', { name: /email notifications/i });
    await expect(emailSwitch).toHaveAttribute('aria-checked', 'false');

    await emailSwitch.click();
    await expect(emailSwitch).toHaveAttribute('aria-checked', 'true');
  });

  test('opens confirmation dialog and saves changes', async ({ page }) => {
    await page.getByRole('button', { name: /save changes/i }).click();

    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible();
    await expect(dialog.getByText(/are you sure/i)).toBeVisible();

    await dialog.getByRole('button', { name: /confirm/i }).click();
    await expect(dialog).not.toBeVisible();

    await expect(page.getByText(/settings saved/i)).toBeVisible();
  });

  test('supports keyboard navigation across tabs', async ({ page }) => {
    const profileTab = page.getByRole('tab', { name: /profile/i });
    const notificationsTab = page.getByRole('tab', { name: /notifications/i });

    await profileTab.focus();
    await page.keyboard.press('ArrowRight');
    await expect(notificationsTab).toHaveAttribute('aria-selected', 'true');
  });
});

Playwright tests run against real browsers, so they catch issues jsdom might miss — such as animation timing, scroll behavior, and genuine focus events. They are slower than unit tests, so reserve them for critical user flows rather than every component interaction.

Best Practices for Testing Radix UI

Query by Accessible Roles

Always prefer queries like getByRole, getByLabelText, and getByText over getByTestId or querySelector. This ensures your tests validate accessibility at the same time as functionality. If a component is hard to query by role, that is usually a sign it needs an aria-label or associated label.

Use user-event Over fireEvent

The @testing-library/user-event library simulates real browser behavior more accurately than fireEvent. It handles focus, keyboard sequences, and pointer events in a way that closely mirrors actual user interaction. This matters for Radix components, which often rely on complex event sequences.

Test Keyboard Interactions Explicitly

Radix components implement WAI-ARIA patterns, including arrow key navigation, Escape to dismiss, and Tab focus trapping. Always include keyboard-based tests alongside click-based tests. This protects accessibility and catches regressions when Radix updates its internal event handling.

Avoid Testing Radix Internals

Do not assert on internal Radix state, data attributes like data-state unless documented as public API, or component instance methods. These can change between minor versions. Instead, test observable behavior: what the user sees, hears, and can interact with.

Mock External Dependencies, Not Radix

Mock API calls, routing, and context providers as needed, but avoid mocking Radix components themselves. Mocking Radix defeats the purpose of testing real behavior. If a test becomes slow because of Radix internals, consider whether the test belongs at the integration or E2E level instead.

Handle Animations in E2E Tests

Radix components often use CSS animations for enter and exit transitions. In Playwright, use toBeVisible() and toBeHidden() which wait for animations to complete. If animations cause flakiness, consider disabling them in test environments via a data-test attribute or environment flag.

Layer Your Tests Thoughtfully

Conclusion

Testing Radix UI components effectively requires thinking like a user: query by accessible roles, simulate real interactions with user-event and Playwright, and validate keyboard behavior alongside mouse clicks. By layering unit tests for isolated components, integration tests for composed primitives, and E2E tests for full user journeys, you build a safety net that catches regressions while reinforcing accessibility. Radix gives you a strong accessible foundation, but it is your test suite that ensures that foundation stays solid as your application evolves. Start with the patterns in this tutorial, adapt them to your components, and your Radix-powered interfaces will be both reliable and inclusive for every user.

— Ad —

Google AdSense will appear here after approval

← Back to all articles