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.
- Accessibility guarantees: Verify ARIA roles, keyboard interactions, and focus trapping work as expected.
- Behavioral correctness: Ensure open/close states, selected values, and form integrations behave correctly.
- Regression protection: Safeguard against breaking changes when upgrading Radix or refactoring components.
- Confidence at scale: Layered tests give you fast feedback locally and comprehensive coverage in CI.
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
- Unit tests: Cover individual wrapper components, prop handling, and simple interactions.
- Integration tests: Cover composed Radix primitives like Dialog, Tabs, and DropdownMenu with focus and keyboard flows.
- E2E tests: Cover critical user journeys that span multiple pages, real browser behavior, and network interactions.
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.