Source profileQuality 88/100Review permissions

PramodDutta/qaskills/seed-skills/shadcn-component-testing/SKILL.md

shadcn/ui Component Testing

Testing skill for shadcn/ui and Radix UI component libraries covering accessible component testing, dialog and popover testing, form validation testing, data table testing, command palette testing, and theme switching verification.

Source repository stars
195
Declared platforms
4
Static risk flags
1
Last source update
2026-08-04
Source checked
2026-08-04

Decision brief

What it does—and where it fits

You are an expert software engineer specializing in testing shadcn/ui and Radix UI component libraries. When the user asks you to write, review, or debug tests for shadcn/ui components including Dialog, Popover, Form, DataTable, Command palette, and theme switching, follow these…

Best for

    Not for

    • Testing Radix internal state -- Do not assert on data-state attributes; test visible behavior instead.
    • Using CSS selectors for queries -- querySelector('.shadcn-button') is fragile; use ARIA roles.

    Compatibility matrix

    Platform support, with evidence labels

    PlatformStatusEvidenceWhat to check
    CodexDeclaredSource recordInstall path and trigger
    Claude CodeDeclaredSource recordInstall path and trigger
    CursorDeclaredSource recordInstall path and trigger
    Gemini CLIDeclaredSource recordInstall path and trigger
    Open the compatibility checker

    Installation

    Inspect first. Install second.

    The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

    Source-detected install commandSource
    npx skills add https://github.com/PramodDutta/qaskills --skill "seed-skills/shadcn-component-testing"
    Safe inspection promptEditorial

    Inspect the Agent Skill "shadcn/ui Component Testing" from https://github.com/PramodDutta/qaskills/blob/c924c5f7fee5fa410f267031061e492eb051757a/seed-skills/shadcn-component-testing/SKILL.md at commit c924c5f7fee5fa410f267031061e492eb051757a. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

    Workflow

    What the source asks the agent to do

    1. 01

      Test Utilities Setup

      Review the “Test Utilities Setup” section in the pinned source before continuing.

      Review and apply the “Test Utilities Setup” source section.
    2. 02

      Core Principles

      1. Test user interactions, not Radix internals -- Radix primitives are well-tested; focus on your composition and customization. 2. Use accessible queries -- Prefer getByRole, getByLabelText, and getByText over CSS selectors to ensure ARIA compliance. 3. Test keyboard navigation…

      Test user interactions, not Radix internals -- Radix primitives are well-tested; focus on your composition and customization.Use accessible queries -- Prefer getByRole, getByLabelText, and getByText over CSS selectors to ensure ARIA compliance.Test keyboard navigation -- shadcn/ui components support full keyboard interaction; verify tab order, arrow keys, and escape.
    3. 03

      Project Structure

      Review the “Project Structure” section in the pinned source before continuing.

      Review and apply the “Project Structure” source section.
    4. 04

      Dialog Testing

      Review the “Dialog Testing” section in the pinned source before continuing.

      Review and apply the “Dialog Testing” source section.
    5. 05

      Popover and Dropdown Testing

      Review the “Popover and Dropdown Testing” section in the pinned source before continuing.

      Review and apply the “Popover and Dropdown Testing” source section.

    Permission review

    Static risk signals and limitations

    Runs scripts

    medium · line 1189

    The documentation asks the agent to run terminal commands or scripts.

    npx vitest run src/components/__tests__/

    Runs scripts

    medium · line 1192

    The documentation asks the agent to run terminal commands or scripts.

    npx vitest run src/components/__tests__/dialog.test.tsx

    Evidence record

    Why each signal appears

    EvidenceSourceComputedTestedEditorial
    SignalValueEvidence typeMeaning
    Quality score88/100ComputedDocumentation, specificity, maintenance, and trust rules
    Repository stars195SourceRepository attention, not individual Skill quality
    Compatibility4 platformsSourceDeclared in the catalog source record
    Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

    Pinned source

    Provenance and original SKILL.md

    Repository
    PramodDutta/qaskills
    Skill path
    seed-skills/shadcn-component-testing/SKILL.md
    Commit
    c924c5f7fee5fa410f267031061e492eb051757a
    License
    MIT
    Collected
    2026-08-04
    Default branch
    main
    View the original SKILL.md

    shadcn/ui Component Testing Skill

    You are an expert software engineer specializing in testing shadcn/ui and Radix UI component libraries. When the user asks you to write, review, or debug tests for shadcn/ui components including Dialog, Popover, Form, DataTable, Command palette, and theme switching, follow these detailed instructions.

    Core Principles

    1. Test user interactions, not Radix internals -- Radix primitives are well-tested; focus on your composition and customization.
    2. Use accessible queries -- Prefer getByRole, getByLabelText, and getByText over CSS selectors to ensure ARIA compliance.
    3. Test keyboard navigation -- shadcn/ui components support full keyboard interaction; verify tab order, arrow keys, and escape.
    4. Verify portal rendering -- Dialogs, popovers, and dropdowns render in portals; use screen queries, not container queries.
    5. Test form integration -- shadcn/ui forms use react-hook-form + zod; test validation messages and submission behavior.
    6. Assert on visual states -- Test open/closed, disabled, loading, and error states explicitly.
    7. Test theme switching -- Verify components render correctly in both light and dark modes.

    Project Structure

    project/
      src/
        components/
          ui/
            button.tsx
            dialog.tsx
            popover.tsx
            select.tsx
            form.tsx
            data-table.tsx
            command.tsx
            sheet.tsx
            accordion.tsx
            toast.tsx
          __tests__/
            button.test.tsx
            dialog.test.tsx
            popover.test.tsx
            select.test.tsx
            form.test.tsx
            data-table.test.tsx
            command.test.tsx
            sheet.test.tsx
            accordion.test.tsx
            toast.test.tsx
            theme-switching.test.tsx
            keyboard-navigation.test.tsx
          test-utils/
            render-with-providers.tsx
            mock-data.ts
            accessibility-helpers.ts
      vitest.config.ts
      playwright.config.ts
    

    Test Utilities Setup

    // src/components/test-utils/render-with-providers.tsx
    import React, { ReactElement } from 'react';
    import { render, RenderOptions } from '@testing-library/react';
    import { ThemeProvider } from 'next-themes';
    import { Toaster } from '@/components/ui/toaster';
    
    interface TestProviderOptions {
      theme?: 'light' | 'dark' | 'system';
    }
    
    function TestProviders({
      children,
      theme = 'light',
    }: {
      children: React.ReactNode;
      theme?: string;
    }) {
      return (
        <ThemeProvider attribute="class" defaultTheme={theme} enableSystem={false}>
          {children}
          <Toaster />
        </ThemeProvider>
      );
    }
    
    export function renderWithProviders(
      ui: ReactElement,
      options?: RenderOptions & TestProviderOptions
    ) {
      const { theme, ...renderOptions } = options || {};
    
      return render(ui, {
        wrapper: ({ children }) => (
          <TestProviders theme={theme}>{children}</TestProviders>
        ),
        ...renderOptions,
      });
    }
    
    export * from '@testing-library/react';
    export { renderWithProviders as render };
    
    // src/components/test-utils/accessibility-helpers.ts
    import { axe, toHaveNoViolations } from 'jest-axe';
    
    expect.extend(toHaveNoViolations);
    
    export async function expectNoA11yViolations(container: HTMLElement) {
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    }
    
    export function expectFocusVisible(element: HTMLElement) {
      expect(element).toHaveFocus();
      expect(document.activeElement).toBe(element);
    }
    
    export function expectAriaExpanded(element: HTMLElement, expanded: boolean) {
      expect(element).toHaveAttribute('aria-expanded', String(expanded));
    }
    
    export function expectAriaSelected(element: HTMLElement, selected: boolean) {
      expect(element).toHaveAttribute('aria-selected', String(selected));
    }
    

    Dialog Testing

    // src/components/__tests__/dialog.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import {
      Dialog,
      DialogTrigger,
      DialogContent,
      DialogHeader,
      DialogTitle,
      DialogDescription,
      DialogFooter,
      DialogClose,
    } from '@/components/ui/dialog';
    import { Button } from '@/components/ui/button';
    import { expectNoA11yViolations } from '../test-utils/accessibility-helpers';
    
    function ConfirmDialog({
      onConfirm,
      onCancel,
    }: {
      onConfirm: () => void;
      onCancel?: () => void;
    }) {
      return (
        <Dialog>
          <DialogTrigger asChild>
            <Button>Delete Item</Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Are you sure?</DialogTitle>
              <DialogDescription>
                This action cannot be undone. This will permanently delete the item.
              </DialogDescription>
            </DialogHeader>
            <DialogFooter>
              <DialogClose asChild>
                <Button variant="outline" onClick={onCancel}>
                  Cancel
                </Button>
              </DialogClose>
              <Button variant="destructive" onClick={onConfirm}>
                Delete
              </Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      );
    }
    
    describe('Dialog', () => {
      it('should open when trigger is clicked', async () => {
        const user = userEvent.setup();
        render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        // Dialog content should not be visible initially
        expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    
        // Click trigger to open
        await user.click(screen.getByRole('button', { name: /delete item/i }));
    
        // Dialog should be visible
        expect(screen.getByRole('dialog')).toBeInTheDocument();
        expect(screen.getByText('Are you sure?')).toBeInTheDocument();
        expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument();
      });
    
      it('should close when escape key is pressed', async () => {
        const user = userEvent.setup();
        render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
        expect(screen.getByRole('dialog')).toBeInTheDocument();
    
        await user.keyboard('{Escape}');
    
        await waitFor(() => {
          expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
        });
      });
    
      it('should close when overlay is clicked', async () => {
        const user = userEvent.setup();
        render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
        expect(screen.getByRole('dialog')).toBeInTheDocument();
    
        // Click the overlay (outside dialog content)
        const overlay = document.querySelector('[data-state="open"][data-overlay]');
        if (overlay) {
          await user.click(overlay as HTMLElement);
          await waitFor(() => {
            expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
          });
        }
      });
    
      it('should call onConfirm when delete button is clicked', async () => {
        const user = userEvent.setup();
        const onConfirm = vi.fn();
        render(<ConfirmDialog onConfirm={onConfirm} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
        await user.click(screen.getByRole('button', { name: /^delete$/i }));
    
        expect(onConfirm).toHaveBeenCalledTimes(1);
      });
    
      it('should close when cancel button is clicked', async () => {
        const user = userEvent.setup();
        const onCancel = vi.fn();
        render(<ConfirmDialog onConfirm={vi.fn()} onCancel={onCancel} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
        await user.click(screen.getByRole('button', { name: /cancel/i }));
    
        expect(onCancel).toHaveBeenCalledTimes(1);
        await waitFor(() => {
          expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
        });
      });
    
      it('should trap focus inside the dialog', async () => {
        const user = userEvent.setup();
        render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
    
        // Tab through dialog elements
        await user.tab();
        const cancelBtn = screen.getByRole('button', { name: /cancel/i });
        const deleteBtn = screen.getByRole('button', { name: /^delete$/i });
    
        // Focus should cycle within dialog
        const focusableElements = [cancelBtn, deleteBtn];
        for (const el of focusableElements) {
          expect(document.activeElement === el || focusableElements.includes(document.activeElement as HTMLElement)).toBe(true);
          await user.tab();
        }
      });
    
      it('should have correct ARIA attributes', async () => {
        const user = userEvent.setup();
        render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
    
        const dialog = screen.getByRole('dialog');
        expect(dialog).toHaveAttribute('aria-labelledby');
        expect(dialog).toHaveAttribute('aria-describedby');
    
        const titleId = dialog.getAttribute('aria-labelledby');
        const title = document.getElementById(titleId!);
        expect(title).toHaveTextContent('Are you sure?');
      });
    
      it('should pass accessibility audit', async () => {
        const user = userEvent.setup();
        const { container } = render(<ConfirmDialog onConfirm={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /delete item/i }));
    
        await expectNoA11yViolations(container);
      });
    });
    

    Popover and Dropdown Testing

    // src/components/__tests__/popover.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import {
      Popover,
      PopoverTrigger,
      PopoverContent,
    } from '@/components/ui/popover';
    import {
      DropdownMenu,
      DropdownMenuTrigger,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuSeparator,
      DropdownMenuLabel,
    } from '@/components/ui/dropdown-menu';
    import { Button } from '@/components/ui/button';
    
    function UserMenu({ onLogout }: { onLogout: () => void }) {
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost">Profile</Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuLabel>My Account</DropdownMenuLabel>
            <DropdownMenuSeparator />
            <DropdownMenuItem>Settings</DropdownMenuItem>
            <DropdownMenuItem>Billing</DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem onClick={onLogout}>Log out</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      );
    }
    
    describe('DropdownMenu', () => {
      it('should open on click and show menu items', async () => {
        const user = userEvent.setup();
        render(<UserMenu onLogout={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /profile/i }));
    
        expect(screen.getByText('My Account')).toBeInTheDocument();
        expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
        expect(screen.getByRole('menuitem', { name: /billing/i })).toBeInTheDocument();
        expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument();
      });
    
      it('should navigate items with arrow keys', async () => {
        const user = userEvent.setup();
        render(<UserMenu onLogout={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /profile/i }));
    
        // Arrow down to navigate
        await user.keyboard('{ArrowDown}');
        expect(screen.getByRole('menuitem', { name: /settings/i })).toHaveFocus();
    
        await user.keyboard('{ArrowDown}');
        expect(screen.getByRole('menuitem', { name: /billing/i })).toHaveFocus();
    
        await user.keyboard('{ArrowDown}');
        expect(screen.getByRole('menuitem', { name: /log out/i })).toHaveFocus();
      });
    
      it('should call onLogout when Log out is clicked', async () => {
        const user = userEvent.setup();
        const onLogout = vi.fn();
        render(<UserMenu onLogout={onLogout} />);
    
        await user.click(screen.getByRole('button', { name: /profile/i }));
        await user.click(screen.getByRole('menuitem', { name: /log out/i }));
    
        expect(onLogout).toHaveBeenCalledTimes(1);
      });
    
      it('should close on escape', async () => {
        const user = userEvent.setup();
        render(<UserMenu onLogout={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /profile/i }));
        expect(screen.getByText('My Account')).toBeInTheDocument();
    
        await user.keyboard('{Escape}');
    
        await waitFor(() => {
          expect(screen.queryByText('My Account')).not.toBeInTheDocument();
        });
      });
    
      it('should select item with Enter key', async () => {
        const user = userEvent.setup();
        const onLogout = vi.fn();
        render(<UserMenu onLogout={onLogout} />);
    
        await user.click(screen.getByRole('button', { name: /profile/i }));
        await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{Enter}');
    
        expect(onLogout).toHaveBeenCalledTimes(1);
      });
    });
    

    Select Component Testing

    // src/components/__tests__/select.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import {
      Select,
      SelectTrigger,
      SelectValue,
      SelectContent,
      SelectItem,
      SelectGroup,
      SelectLabel,
    } from '@/components/ui/select';
    
    function PrioritySelect({
      value,
      onChange,
    }: {
      value?: string;
      onChange: (value: string) => void;
    }) {
      return (
        <Select value={value} onValueChange={onChange}>
          <SelectTrigger aria-label="Priority">
            <SelectValue placeholder="Select priority" />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              <SelectLabel>Priority</SelectLabel>
              <SelectItem value="low">Low</SelectItem>
              <SelectItem value="medium">Medium</SelectItem>
              <SelectItem value="high">High</SelectItem>
              <SelectItem value="critical" disabled>
                Critical
              </SelectItem>
            </SelectGroup>
          </SelectContent>
        </Select>
      );
    }
    
    describe('Select', () => {
      it('should show placeholder when no value selected', () => {
        render(<PrioritySelect onChange={vi.fn()} />);
    
        expect(screen.getByText('Select priority')).toBeInTheDocument();
      });
    
      it('should open options on click', async () => {
        const user = userEvent.setup();
        render(<PrioritySelect onChange={vi.fn()} />);
    
        await user.click(screen.getByRole('combobox', { name: /priority/i }));
    
        expect(screen.getByRole('option', { name: /low/i })).toBeInTheDocument();
        expect(screen.getByRole('option', { name: /medium/i })).toBeInTheDocument();
        expect(screen.getByRole('option', { name: /high/i })).toBeInTheDocument();
      });
    
      it('should call onChange when option is selected', async () => {
        const user = userEvent.setup();
        const onChange = vi.fn();
        render(<PrioritySelect onChange={onChange} />);
    
        await user.click(screen.getByRole('combobox', { name: /priority/i }));
        await user.click(screen.getByRole('option', { name: /high/i }));
    
        expect(onChange).toHaveBeenCalledWith('high');
      });
    
      it('should not allow selecting disabled options', async () => {
        const user = userEvent.setup();
        const onChange = vi.fn();
        render(<PrioritySelect onChange={onChange} />);
    
        await user.click(screen.getByRole('combobox', { name: /priority/i }));
        const criticalOption = screen.getByRole('option', { name: /critical/i });
    
        expect(criticalOption).toHaveAttribute('aria-disabled', 'true');
      });
    
      it('should display selected value', () => {
        render(<PrioritySelect value="medium" onChange={vi.fn()} />);
    
        expect(screen.getByText('Medium')).toBeInTheDocument();
      });
    
      it('should support keyboard navigation', async () => {
        const user = userEvent.setup();
        const onChange = vi.fn();
        render(<PrioritySelect onChange={onChange} />);
    
        // Open with Enter
        const trigger = screen.getByRole('combobox', { name: /priority/i });
        trigger.focus();
        await user.keyboard('{Enter}');
    
        // Navigate with arrows and select with Enter
        await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
    
        expect(onChange).toHaveBeenCalled();
      });
    });
    

    Form Testing with react-hook-form + zod

    // src/components/__tests__/form.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import { useForm } from 'react-hook-form';
    import { zodResolver } from '@hookform/resolvers/zod';
    import { z } from 'zod';
    import {
      Form,
      FormControl,
      FormDescription,
      FormField,
      FormItem,
      FormLabel,
      FormMessage,
    } from '@/components/ui/form';
    import { Input } from '@/components/ui/input';
    import { Button } from '@/components/ui/button';
    
    const profileSchema = z.object({
      username: z
        .string()
        .min(3, 'Username must be at least 3 characters')
        .max(20, 'Username must be at most 20 characters')
        .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
      email: z.string().email('Please enter a valid email address'),
      bio: z.string().max(160, 'Bio must be at most 160 characters').optional(),
    });
    
    type ProfileFormValues = z.infer<typeof profileSchema>;
    
    function ProfileForm({ onSubmit }: { onSubmit: (data: ProfileFormValues) => void }) {
      const form = useForm<ProfileFormValues>({
        resolver: zodResolver(profileSchema),
        defaultValues: { username: '', email: '', bio: '' },
      });
    
      return (
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} data-testid="profile-form">
            <FormField
              control={form.control}
              name="username"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Username</FormLabel>
                  <FormControl>
                    <Input placeholder="johndoe" {...field} />
                  </FormControl>
                  <FormDescription>Your public display name.</FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="[email protected]" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="bio"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Bio</FormLabel>
                  <FormControl>
                    <Input placeholder="Tell us about yourself" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit">Save</Button>
          </form>
        </Form>
      );
    }
    
    describe('ProfileForm', () => {
      it('should render all form fields', () => {
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
        expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
        expect(screen.getByLabelText(/bio/i)).toBeInTheDocument();
        expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
      });
    
      it('should show validation errors for empty required fields', async () => {
        const user = userEvent.setup();
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
          expect(screen.getByText(/valid email/i)).toBeInTheDocument();
        });
      });
    
      it('should show validation error for short username', async () => {
        const user = userEvent.setup();
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        await user.type(screen.getByLabelText(/username/i), 'ab');
        await user.type(screen.getByLabelText(/email/i), '[email protected]');
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
        });
      });
    
      it('should show validation error for invalid username characters', async () => {
        const user = userEvent.setup();
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        await user.type(screen.getByLabelText(/username/i), 'user name!');
        await user.type(screen.getByLabelText(/email/i), '[email protected]');
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(screen.getByText(/only contain letters, numbers/i)).toBeInTheDocument();
        });
      });
    
      it('should show validation error for invalid email', async () => {
        const user = userEvent.setup();
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        await user.type(screen.getByLabelText(/username/i), 'validuser');
        await user.type(screen.getByLabelText(/email/i), 'not-an-email');
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(screen.getByText(/valid email/i)).toBeInTheDocument();
        });
      });
    
      it('should submit valid form data', async () => {
        const user = userEvent.setup();
        const onSubmit = vi.fn();
        render(<ProfileForm onSubmit={onSubmit} />);
    
        await user.type(screen.getByLabelText(/username/i), 'johndoe');
        await user.type(screen.getByLabelText(/email/i), '[email protected]');
        await user.type(screen.getByLabelText(/bio/i), 'Hello world');
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(onSubmit).toHaveBeenCalledWith(
            { username: 'johndoe', email: '[email protected]', bio: 'Hello world' },
            expect.anything()
          );
        });
      });
    
      it('should clear errors when valid input is provided', async () => {
        const user = userEvent.setup();
        render(<ProfileForm onSubmit={vi.fn()} />);
    
        // Trigger validation errors
        await user.click(screen.getByRole('button', { name: /save/i }));
        await waitFor(() => {
          expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
        });
    
        // Fix the error
        await user.type(screen.getByLabelText(/username/i), 'validuser');
        await user.type(screen.getByLabelText(/email/i), '[email protected]');
        await user.click(screen.getByRole('button', { name: /save/i }));
    
        await waitFor(() => {
          expect(screen.queryByText(/at least 3 characters/i)).not.toBeInTheDocument();
        });
      });
    });
    

    DataTable Testing

    // src/components/__tests__/data-table.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, within } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    
    // Assume a DataTable component built with @tanstack/react-table + shadcn/ui
    import { DataTable, columns } from '@/components/data-table';
    
    const mockData = [
      { id: '1', name: 'Alice', email: '[email protected]', role: 'admin', status: 'active' },
      { id: '2', name: 'Bob', email: '[email protected]', role: 'user', status: 'active' },
      { id: '3', name: 'Charlie', email: '[email protected]', role: 'user', status: 'inactive' },
      { id: '4', name: 'Diana', email: '[email protected]', role: 'admin', status: 'active' },
      { id: '5', name: 'Eve', email: '[email protected]', role: 'user', status: 'active' },
    ];
    
    describe('DataTable', () => {
      it('should render all rows', () => {
        render(<DataTable columns={columns} data={mockData} />);
    
        const rows = screen.getAllByRole('row');
        // Header row + 5 data rows
        expect(rows).toHaveLength(6);
      });
    
      it('should render column headers', () => {
        render(<DataTable columns={columns} data={mockData} />);
    
        expect(screen.getByRole('columnheader', { name: /name/i })).toBeInTheDocument();
        expect(screen.getByRole('columnheader', { name: /email/i })).toBeInTheDocument();
        expect(screen.getByRole('columnheader', { name: /role/i })).toBeInTheDocument();
        expect(screen.getByRole('columnheader', { name: /status/i })).toBeInTheDocument();
      });
    
      it('should sort by column when header is clicked', async () => {
        const user = userEvent.setup();
        render(<DataTable columns={columns} data={mockData} />);
    
        // Click name column header to sort ascending
        await user.click(screen.getByRole('columnheader', { name: /name/i }));
    
        const rows = screen.getAllByRole('row').slice(1); // Skip header
        const names = rows.map((row) => within(row).getAllByRole('cell')[0].textContent);
        expect(names).toEqual(['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']);
    
        // Click again for descending
        await user.click(screen.getByRole('columnheader', { name: /name/i }));
    
        const rowsDesc = screen.getAllByRole('row').slice(1);
        const namesDesc = rowsDesc.map((row) => within(row).getAllByRole('cell')[0].textContent);
        expect(namesDesc).toEqual(['Eve', 'Diana', 'Charlie', 'Bob', 'Alice']);
      });
    
      it('should filter rows by search input', async () => {
        const user = userEvent.setup();
        render(<DataTable columns={columns} data={mockData} searchColumn="name" />);
    
        const searchInput = screen.getByPlaceholderText(/filter/i);
        await user.type(searchInput, 'alice');
    
        const rows = screen.getAllByRole('row').slice(1);
        expect(rows).toHaveLength(1);
        expect(within(rows[0]).getByText('Alice')).toBeInTheDocument();
      });
    
      it('should show empty state when no data matches', async () => {
        const user = userEvent.setup();
        render(<DataTable columns={columns} data={mockData} searchColumn="name" />);
    
        const searchInput = screen.getByPlaceholderText(/filter/i);
        await user.type(searchInput, 'nonexistent');
    
        expect(screen.getByText(/no results/i)).toBeInTheDocument();
      });
    
      it('should handle row selection with checkboxes', async () => {
        const user = userEvent.setup();
        const onSelectionChange = vi.fn();
        render(
          <DataTable
            columns={columns}
            data={mockData}
            enableSelection
            onSelectionChange={onSelectionChange}
          />
        );
    
        const checkboxes = screen.getAllByRole('checkbox');
        // First checkbox is "select all", rest are row checkboxes
        expect(checkboxes).toHaveLength(6);
    
        // Select first row
        await user.click(checkboxes[1]);
        expect(onSelectionChange).toHaveBeenCalledWith(
          expect.objectContaining({ '1': true })
        );
      });
    
      it('should handle pagination', async () => {
        const user = userEvent.setup();
        const largeData = Array.from({ length: 25 }, (_, i) => ({
          id: String(i),
          name: `User ${i}`,
          email: `user${i}@example.com`,
          role: 'user',
          status: 'active',
        }));
    
        render(<DataTable columns={columns} data={largeData} pageSize={10} />);
    
        // First page should show 10 rows
        const rows = screen.getAllByRole('row').slice(1);
        expect(rows).toHaveLength(10);
    
        // Navigate to next page
        const nextButton = screen.getByRole('button', { name: /next/i });
        await user.click(nextButton);
    
        // Second page should also show 10 rows
        const page2Rows = screen.getAllByRole('row').slice(1);
        expect(page2Rows).toHaveLength(10);
      });
    });
    

    Command Palette (cmdk) Testing

    // src/components/__tests__/command.test.tsx
    import { describe, it, expect, vi } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import {
      CommandDialog,
      CommandInput,
      CommandList,
      CommandEmpty,
      CommandGroup,
      CommandItem,
      CommandSeparator,
    } from '@/components/ui/command';
    
    function AppCommandPalette({
      open,
      onOpenChange,
      onSelect,
    }: {
      open: boolean;
      onOpenChange: (open: boolean) => void;
      onSelect: (value: string) => void;
    }) {
      return (
        <CommandDialog open={open} onOpenChange={onOpenChange}>
          <CommandInput placeholder="Type a command or search..." />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup heading="Pages">
              <CommandItem onSelect={() => onSelect('/dashboard')}>Dashboard</CommandItem>
              <CommandItem onSelect={() => onSelect('/settings')}>Settings</CommandItem>
              <CommandItem onSelect={() => onSelect('/profile')}>Profile</CommandItem>
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup heading="Actions">
              <CommandItem onSelect={() => onSelect('new-project')}>New Project</CommandItem>
              <CommandItem onSelect={() => onSelect('new-team')}>New Team</CommandItem>
            </CommandGroup>
          </CommandList>
        </CommandDialog>
      );
    }
    
    describe('Command Palette', () => {
      it('should render when open', () => {
        render(
          <AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
        );
    
        expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument();
        expect(screen.getByText('Dashboard')).toBeInTheDocument();
        expect(screen.getByText('Settings')).toBeInTheDocument();
      });
    
      it('should filter items by search input', async () => {
        const user = userEvent.setup();
        render(
          <AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
        );
    
        await user.type(screen.getByPlaceholderText(/type a command/i), 'dash');
    
        expect(screen.getByText('Dashboard')).toBeInTheDocument();
        expect(screen.queryByText('Settings')).not.toBeInTheDocument();
        expect(screen.queryByText('Profile')).not.toBeInTheDocument();
      });
    
      it('should show empty state when nothing matches', async () => {
        const user = userEvent.setup();
        render(
          <AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
        );
    
        await user.type(screen.getByPlaceholderText(/type a command/i), 'zzzzz');
    
        expect(screen.getByText('No results found.')).toBeInTheDocument();
      });
    
      it('should call onSelect when item is clicked', async () => {
        const user = userEvent.setup();
        const onSelect = vi.fn();
        render(
          <AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={onSelect} />
        );
    
        await user.click(screen.getByText('Dashboard'));
    
        expect(onSelect).toHaveBeenCalledWith('/dashboard');
      });
    
      it('should navigate with arrow keys and select with Enter', async () => {
        const user = userEvent.setup();
        const onSelect = vi.fn();
        render(
          <AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={onSelect} />
        );
    
        const input = screen.getByPlaceholderText(/type a command/i);
        await user.click(input);
        await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
    
        expect(onSelect).toHaveBeenCalled();
      });
    });
    

    Theme Switching Testing

    // src/components/__tests__/theme-switching.test.tsx
    import { describe, it, expect } from 'vitest';
    import { render, screen, waitFor } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import { Button } from '@/components/ui/button';
    import { useTheme } from 'next-themes';
    
    function ThemeToggle() {
      const { theme, setTheme } = useTheme();
    
      return (
        <Button
          variant="outline"
          onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
          aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
        >
          {theme === 'light' ? 'Dark Mode' : 'Light Mode'}
        </Button>
      );
    }
    
    describe('Theme Switching', () => {
      it('should render with light theme by default', () => {
        render(<ThemeToggle />, { theme: 'light' });
    
        expect(screen.getByRole('button', { name: /switch to dark/i })).toBeInTheDocument();
      });
    
      it('should render with dark theme when configured', () => {
        render(<ThemeToggle />, { theme: 'dark' });
    
        expect(screen.getByRole('button', { name: /switch to light/i })).toBeInTheDocument();
      });
    
      it('should toggle theme on button click', async () => {
        const user = userEvent.setup();
        render(<ThemeToggle />, { theme: 'light' });
    
        await user.click(screen.getByRole('button', { name: /switch to dark/i }));
    
        await waitFor(() => {
          expect(screen.getByRole('button', { name: /switch to light/i })).toBeInTheDocument();
        });
      });
    
      it('should apply correct CSS class to document', async () => {
        const user = userEvent.setup();
        render(<ThemeToggle />, { theme: 'light' });
    
        await user.click(screen.getByRole('button', { name: /switch to dark/i }));
    
        await waitFor(() => {
          expect(document.documentElement.classList.contains('dark')).toBe(true);
        });
      });
    });
    

    Accordion Testing

    // src/components/__tests__/accordion.test.tsx
    import { describe, it, expect } from 'vitest';
    import { render, screen } from '../test-utils/render-with-providers';
    import userEvent from '@testing-library/user-event';
    import {
      Accordion,
      AccordionItem,
      AccordionTrigger,
      AccordionContent,
    } from '@/components/ui/accordion';
    
    function FAQ() {
      return (
        <Accordion type="single" collapsible>
          <AccordionItem value="item-1">
            <AccordionTrigger>What is shadcn/ui?</AccordionTrigger>
            <AccordionContent>
              A collection of reusable components built with Radix UI and Tailwind CSS.
            </AccordionContent>
          </AccordionItem>
          <AccordionItem value="item-2">
            <AccordionTrigger>Is it accessible?</AccordionTrigger>
            <AccordionContent>
              Yes, it follows WAI-ARIA design patterns.
            </AccordionContent>
          </AccordionItem>
        </Accordion>
      );
    }
    
    describe('Accordion', () => {
      it('should render all triggers', () => {
        render(<FAQ />);
    
        expect(screen.getByRole('button', { name: /what is shadcn/i })).toBeInTheDocument();
        expect(screen.getByRole('button', { name: /is it accessible/i })).toBeInTheDocument();
      });
    
      it('should expand content when trigger is clicked', async () => {
        const user = userEvent.setup();
        render(<FAQ />);
    
        await user.click(screen.getByRole('button', { name: /what is shadcn/i }));
    
        expect(screen.getByText(/reusable components/i)).toBeVisible();
      });
    
      it('should collapse when clicking the same trigger again', async () => {
        const user = userEvent.setup();
        render(<FAQ />);
    
        const trigger = screen.getByRole('button', { name: /what is shadcn/i });
        await user.click(trigger);
        expect(screen.getByText(/reusable components/i)).toBeVisible();
    
        await user.click(trigger);
        // Content should be hidden (aria-hidden or removed)
        expect(trigger).toHaveAttribute('aria-expanded', 'false');
      });
    
      it('should close previous item when opening another (single mode)', async () => {
        const user = userEvent.setup();
        render(<FAQ />);
    
        await user.click(screen.getByRole('button', { name: /what is shadcn/i }));
        expect(screen.getByText(/reusable components/i)).toBeVisible();
    
        await user.click(screen.getByRole('button', { name: /is it accessible/i }));
        expect(screen.getByText(/WAI-ARIA/i)).toBeVisible();
    
        // First item should be collapsed
        const firstTrigger = screen.getByRole('button', { name: /what is shadcn/i });
        expect(firstTrigger).toHaveAttribute('aria-expanded', 'false');
      });
    
      it('should support keyboard navigation', async () => {
        const user = userEvent.setup();
        render(<FAQ />);
    
        const firstTrigger = screen.getByRole('button', { name: /what is shadcn/i });
        firstTrigger.focus();
    
        // Space should toggle
        await user.keyboard(' ');
        expect(firstTrigger).toHaveAttribute('aria-expanded', 'true');
    
        // Enter should also toggle
        await user.keyboard('{Enter}');
        expect(firstTrigger).toHaveAttribute('aria-expanded', 'false');
      });
    });
    

    E2E Tests with Playwright

    // e2e/components.spec.ts
    import { test, expect } from '@playwright/test';
    
    test.describe('shadcn/ui Components E2E', () => {
      test('dialog should open and close with keyboard', async ({ page }) => {
        await page.goto('/components/dialog-demo');
    
        await page.getByRole('button', { name: /open dialog/i }).click();
        await expect(page.getByRole('dialog')).toBeVisible();
    
        // Close with Escape
        await page.keyboard.press('Escape');
        await expect(page.getByRole('dialog')).not.toBeVisible();
      });
    
      test('command palette should open with Cmd+K', async ({ page }) => {
        await page.goto('/dashboard');
    
        // Open command palette with keyboard shortcut
        await page.keyboard.press('Meta+k');
        await expect(page.getByPlaceholder(/type a command/i)).toBeVisible();
    
        // Search and select
        await page.getByPlaceholder(/type a command/i).fill('settings');
        await page.keyboard.press('Enter');
        await expect(page).toHaveURL(/\/settings/);
      });
    
      test('data table should sort and filter', async ({ page }) => {
        await page.goto('/dashboard/users');
    
        // Sort by name
        await page.getByRole('columnheader', { name: /name/i }).click();
        const firstRow = page.getByRole('row').nth(1);
        await expect(firstRow.getByRole('cell').first()).toHaveText(/^A/);
    
        // Filter
        await page.getByPlaceholder(/filter/i).fill('admin');
        const rows = page.getByRole('row');
        await expect(rows).toHaveCount(3); // Header + 2 admin rows
      });
    
      test('form should show validation errors and submit', async ({ page }) => {
        await page.goto('/profile/edit');
    
        // Submit empty form
        await page.getByRole('button', { name: /save/i }).click();
        await expect(page.getByText(/at least 3 characters/i)).toBeVisible();
    
        // Fill valid data
        await page.getByLabel(/username/i).fill('testuser');
        await page.getByLabel(/email/i).fill('[email protected]');
        await page.getByRole('button', { name: /save/i }).click();
    
        await expect(page.getByText(/saved successfully/i)).toBeVisible();
      });
    });
    

    Best Practices

    1. Use getByRole over getByTestId -- Role queries test accessibility for free; test IDs skip it.
    2. Always use userEvent over fireEvent -- userEvent simulates real browser interactions including focus.
    3. Wrap state changes in waitFor -- Radix components use animations; state changes may be async.
    4. Test portal-rendered content with screen -- Dialogs and popovers render outside the component tree.
    5. Run axe audits on interactive states -- Test accessibility of both closed and open states.
    6. Mock next-themes for predictable theme testing -- Avoid relying on browser/OS theme preferences.
    7. Test disabled states explicitly -- Verify that disabled buttons, inputs, and menu items are not interactive.
    8. Use within() for scoped queries -- When testing tables or lists, scope queries to a specific row or item.
    9. Test the complete form lifecycle -- Empty submit, validation errors, correction, successful submit.
    10. Keep component tests focused -- Test one component behavior per test; compose for integration tests.

    Anti-Patterns to Avoid

    1. Testing Radix internal state -- Do not assert on data-state attributes; test visible behavior instead.
    2. Using CSS selectors for queries -- querySelector('.shadcn-button') is fragile; use ARIA roles.
    3. Forgetting to wait for animations -- Radix uses enter/exit animations; wrap assertions in waitFor.
    4. Testing styled-component class names -- Tailwind classes are implementation details; test visual output.
    5. Skipping keyboard interaction tests -- Many users rely on keyboard navigation; it must work.
    6. Mocking Radix primitives -- Never mock the component library; test the real components.
    7. Testing only the open state -- Verify that closed/collapsed states also render correctly.
    8. Ignoring focus management -- After a dialog closes, focus should return to the trigger element.
    9. Not testing with screen readers -- Run automated ARIA checks and manual VoiceOver/NVDA testing.
    10. Hardcoding animation durations in tests -- Use waitFor instead of setTimeout for animation timing.

    Running Tests

    # Run all component tests
    npx vitest run src/components/__tests__/
    
    # Run a specific component test
    npx vitest run src/components/__tests__/dialog.test.tsx
    
    # Run with coverage
    npx vitest run src/components/__tests__/ --coverage
    
    # Watch mode for development
    npx vitest watch src/components/__tests__/
    
    # Run E2E tests
    npx playwright test e2e/components.spec.ts
    
    # Run E2E with UI mode
    npx playwright test --ui
    
    # Run accessibility audit
    npx vitest run src/components/__tests__/ --reporter=verbose
    
    # Debug a failing test
    npx vitest run src/components/__tests__/form.test.tsx --reporter=verbose
    

    Alternatives

    Compare before choosing