shadcn/ui Testing Guide: RTL, Accessibility, and Form Validation Patterns

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:

  1. Base behavior: Dialog opens and closes, Select selects values, Form validates
  2. 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 jsdom

Configure 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 email

HelpMeTest's self-healing tests adapt when shadcn/ui component structure changes after updates.

Summary

Testing shadcn/ui components effectively:

  1. Set up jsdom mocks for ResizeObserver and pointer events before any tests run
  2. Test behavior, not implementation: use roles, labels, and text — not Radix data attributes
  3. Use userEvent.setup() (not fireEvent) for realistic interaction simulation
  4. Always waitFor after opening Dialogs, Selects, and Toasts — they render asynchronously
  5. Run jest-axe on every component that accepts user interaction
  6. Test your customizations explicitly — you own the source, so changes need test coverage

Read more