shadcn/ui Testing Guide: RTL, Accessibility, and Form Validation Patterns
shadcn/ui is not a component library you install — it's a collection of components you copy into your codebase, built on Radix UI primitives and styled with Tailwind CSS. Because you own the source code, testing shadcn/ui components means testing your actual components, not a third-party package. This distinction matters: you can modify the components freely, and your tests should verify your customizations in addition to the base behavior.
Testing Philosophy for shadcn/ui
Since shadcn/ui components live in your components/ui/ folder, you have two testing responsibilities:
- Base behavior: Dialog opens and closes, Select selects values, Form validates
- Your customizations: Any modifications you've made to the copied components
Start with integration tests that use your components as users would, then add unit tests for custom logic.
Setup
npm install --save-dev @testing-library/react @testing-library/user-event jest-axe @axe-core/react jsdomConfigure vitest.config.ts or jest.config.ts with jsdom:
// vitest.config.ts
export default {
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
};
// src/test/setup.ts
import "@testing-library/jest-dom";
import { beforeAll } from "vitest";
beforeAll(() => {
// Radix UI uses ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Radix UI Tooltip and Popover use pointerEvents
window.HTMLElement.prototype.hasPointerCapture = () => false;
window.HTMLElement.prototype.scrollIntoView = () => {};
});Testing the Button Component
The simplest test verifies click behavior and disabled state:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/ui/button";
it("calls onClick handler when clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Submit</Button>);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(handleClick).toHaveBeenCalledOnce();
});
it("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Submit</Button>);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(handleClick).not.toHaveBeenCalled();
});
it("renders loading spinner and disables interaction", async () => {
render(<Button disabled aria-busy={true}>Saving...</Button>);
const button = screen.getByRole("button", { name: "Saving..." });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
});Testing the Dialog Component
Dialog uses Radix UI's Dialog primitive. Test open/close behavior, keyboard navigation, and focus management:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
function TestDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<button>Open Dialog</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
</DialogHeader>
<p>Are you sure you want to continue?</p>
<DialogClose asChild>
<button>Cancel</button>
</DialogClose>
</DialogContent>
</Dialog>
);
}
it("opens dialog on trigger click", async () => {
const user = userEvent.setup();
render(<TestDialog />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Confirm Action")).toBeVisible();
});
it("closes dialog when Close button is clicked", async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("closes dialog on Escape key press", async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("traps focus within dialog when open", async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
const dialog = screen.getByRole("dialog");
const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
expect(focusableElements.length).toBeGreaterThan(0);
expect(document.activeElement).not.toBe(document.body);
});Testing the Select Component
Select components require careful interaction testing — they involve keyboard navigation and option selection:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function TestSelect({ onChange }: { onChange: (value: string) => void }) {
return (
<Select onValueChange={onChange}>
<SelectTrigger aria-label="Choose framework">
<SelectValue placeholder="Select a framework" />
</SelectTrigger>
<SelectContent>
<SelectItem value="react">React</SelectItem>
<SelectItem value="vue">Vue</SelectItem>
<SelectItem value="svelte">Svelte</SelectItem>
</SelectContent>
</Select>
);
}
it("shows placeholder before selection", () => {
render(<TestSelect onChange={vi.fn()} />);
expect(screen.getByText("Select a framework")).toBeInTheDocument();
});
it("calls onValueChange with selected value", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TestSelect onChange={onChange} />);
await user.click(screen.getByRole("combobox", { name: "Choose framework" }));
await user.click(screen.getByRole("option", { name: "Vue" }));
expect(onChange).toHaveBeenCalledWith("vue");
});
it("displays selected value after selection", async () => {
const user = userEvent.setup();
render(<TestSelect onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "React" }));
expect(screen.getByText("React")).toBeInTheDocument();
});Testing Forms with Validation
shadcn/ui's Form component wraps react-hook-form. Test validation messages and submission behavior:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const schema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type FormData = z.infer<typeof schema>;
function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
const form = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Log in</Button>
</form>
</Form>
);
}
it("shows validation error for invalid email", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Log in" }));
await waitFor(() => {
expect(screen.getByText("Please enter a valid email")).toBeInTheDocument();
});
});
it("shows validation error for short password", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText("Email"), "valid@example.com");
await user.type(screen.getByLabelText("Password"), "short");
await user.click(screen.getByRole("button", { name: "Log in" }));
await waitFor(() => {
expect(screen.getByText("Password must be at least 8 characters")).toBeInTheDocument();
});
});
it("calls onSubmit with form data when valid", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.type(screen.getByLabelText("Password"), "securepassword");
await user.click(screen.getByRole("button", { name: "Log in" }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "securepassword",
});
});
});Accessibility Testing with jest-axe
shadcn/ui is built for accessibility, but your customizations may break it. Test with jest-axe:
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
it("Button has no accessibility violations", async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("Dialog has no accessibility violations when open", async () => {
const user = userEvent.setup();
const { container } = render(<TestDialog />);
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("Form has no accessibility violations", async () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Testing the Toast Component
Toasts appear asynchronously. Test them by triggering the toast and waiting for it to appear:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toaster } from "@/components/ui/toaster";
import { useToast } from "@/components/ui/use-toast";
function ToastTrigger() {
const { toast } = useToast();
return (
<button
onClick={() =>
toast({ title: "Saved", description: "Your changes have been saved." })
}
>
Save
</button>
);
}
it("shows toast notification on action", async () => {
const user = userEvent.setup();
render(
<>
<ToastTrigger />
<Toaster />
</>
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByText("Saved")).toBeInTheDocument();
expect(screen.getByText("Your changes have been saved.")).toBeInTheDocument();
});
});Common Testing Mistakes
Not mocking ResizeObserver: Radix UI components use ResizeObserver for positioning. Without the mock, tests fail with "ResizeObserver is not defined".
Testing Radix internals: Don't test that [data-radix-popper-content-wrapper] is present — test that your content is visible. Radix's implementation details can change.
Using getByText for Select options: Select options are in a portal. Use getByRole("option", { name: "..." }) instead.
Skipping async waitFor: Dialog and Select animations require waitFor after interaction — content appears asynchronously.
End-to-End with HelpMeTest
For production testing of forms and dialogs in your real app, HelpMeTest provides AI-powered browser testing:
*** Test Cases ***
Verify Login Form Validates Email
New Page https://yourapp.com/login
Fill Text [name="email"] invalid-email
Click [type="submit"]
Wait For Element text=Please enter a valid email
Get Text .error-message == Please enter a valid emailHelpMeTest's self-healing tests adapt when shadcn/ui component structure changes after updates.
Summary
Testing shadcn/ui components effectively:
- Set up jsdom mocks for ResizeObserver and pointer events before any tests run
- Test behavior, not implementation: use roles, labels, and text — not Radix data attributes
- Use
userEvent.setup()(notfireEvent) for realistic interaction simulation - Always
waitForafter opening Dialogs, Selects, and Toasts — they render asynchronously - Run
jest-axeon every component that accepts user interaction - Test your customizations explicitly — you own the source, so changes need test coverage