Skip to main content Início Criadores pramoddutta qaskills shadcnui-component-testing
shadcnui-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.
Ir para a instalação Skills Marketplace Descubra e explore skills de IA criadas pela comunidade.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Copiar promptMostrar detalhes do prompt Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
npx skills add https://github.com/PramodDutta/qaskills --skill shadcnui-component-testingO comando permanece em uma só linha. Role horizontalmente para revisá-lo antes de copiar.
Prefere uma cópia local? Baixe os arquivos disponíveis atualmente no SkillsMP.
Baixar Zip Baixando... Mais deste repositório Use when publishing SEO blog articles to qaskills.sh, e.g. "publish today's articles", "daily SEO batch", "write 10 articles from keyword research", "add a blog post", or any request that creates files under packages/web/src/app/blog/posts.
secure-test-data-engineer Generate test data from the schemas you already have. Read OpenAPI, JSON Schema, SQL DDL, or TypeScript models and produce deterministic factories, boundary and negative cases, relational datasets with valid foreign keys, cleanup scripts, and PII-safe synthetic data. Production records never leave the machine.
Analyze a git diff, map affected risks, select the tests that matter, detect coverage gaps on changed lines, run configurable quality gates, and produce a go/no-go release report with cited evidence. Recommends only; never merges or deploys.
Ocupações relacionadas SOC
Baseado na classificação ocupacional SOC
name shadcn/ui Component Testing description 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. version 1.0.0 author thetestingacademy license MIT tags ["shadcn","radix-ui","component-testing","accessibility","dialog","form","data-table","tailwind"] testingTypes ["unit","integration","e2e","accessibility"] frameworks ["vitest","playwright","react-testing-library"] languages ["typescript","javascript"] domains ["web"] agents ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"]
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
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.
Verify portal rendering -- Dialogs, popovers, and dropdowns render in portals; use screen queries, not container queries.
Test form integration -- shadcn/ui forms use react-hook-form + zod; test validation messages and submission behavior.
Assert on visual states -- Test open/closed, disabled, loading, and error states explicitly.
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
import React , { ReactElement } from 'react' ;
import { render, RenderOptions } from '@testing-library/react' ;
{ } ;
{ } ;
{
?: | | ;
}
( ) {
(
);
}
( ) {
{ theme, ...renderOptions } = options || {};
(ui, {
: (
),
...renderOptions,
});
}
* ;
{ renderWithProviders render };
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
return
render
wrapper
({ children } ) =>
<TestProviders theme ={theme} > {children}</TestProviders >
export
from
'@testing-library/react'
export
as
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
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()} /> );
expect (screen.queryByRole ('dialog' )).not .toBeInTheDocument ();
await user.click (screen.getByRole ('button' , { name : /delete item/i }));
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 ();
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 }));
await user.tab ();
const cancelBtn = screen.getByRole ('button' , { name : /cancel/i });
const deleteBtn = screen.getByRole ('button' , { name : /^delete$/i });
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
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 }));
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
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} /> );
const trigger = screen.getByRole ('combobox' , { name : /priority/i });
trigger.focus ();
await user.keyboard ('{Enter}' );
await user.keyboard ('{ArrowDown}{ArrowDown}{Enter}' );
expect (onChange).toHaveBeenCalled ();
});
});
Form Testing with react-hook-form + zod
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 >
< >
)}
/>
(
Bio
)}
/>
Save
);
}
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 ), 'valid@example.com' );
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 ), 'valid@example.com' );
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 ), 'john@example.com' );
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 : 'john@example.com' , bio : 'Hello world' },
expect.anything ()
);
});
});
it ('should clear errors when valid input is provided' , 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 ();
});
await user.type (screen.getByLabelText (/username/i ), 'validuser' );
await user.type (screen.getByLabelText (/email/i ), 'valid@example.com' );
await user.click (screen.getByRole ('button' , { name : /save/i }));
await waitFor (() => {
expect (screen.queryByText (/at least 3 characters/i )).not .toBeInTheDocument ();
});
});
});
DataTable Testing
import { describe, it, expect, vi } from 'vitest' ;
import { render, screen, within } from '../test-utils/render-with-providers' ;
import userEvent from '@testing-library/user-event' ;
import { DataTable , columns } from '@/components/data-table' ;
const mockData = [
{ id : '1' , name : 'Alice' , email : 'alice@example.com' , role : 'admin' , status : 'active' },
{ id : '2' , name : 'Bob' , email : 'bob@example.com' , role : 'user' , status : 'active' },
{ id : '3' , name : 'Charlie' , email : 'charlie@example.com' , role : 'user' , status : 'inactive' },
{ id : '4' , name : 'Diana' , email : 'diana@example.com' , role : 'admin' , status : 'active' },
{ id : '5' , name : 'Eve' , email : 'eve@example.com' , role : 'user' , status : 'active' },
];
describe ('DataTable' , () => {
it ('should render all rows' , () => {
render (<DataTable columns ={columns} data ={mockData} /> );
const rows = screen.getAllByRole ('row' );
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} /> );
await user.click (screen.getByRole ('columnheader' , { name : /name/i }));
const rows = screen.getAllByRole ('row' ).slice (1 );
const names = rows.map ((row ) => within (row).getAllByRole ('cell' )[0 ].textContent );
expect (names).toEqual (['Alice' , 'Bob' , 'Charlie' , 'Diana' , 'Eve' ]);
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' );
expect (checkboxes).toHaveLength (6 );
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} /> );
const rows = screen.getAllByRole ('row' ).slice (1 );
expect (rows).toHaveLength (10 );
const nextButton = screen.getByRole ('button' , { name : /next/i });
await user.click (nextButton);
const page2Rows = screen.getAllByRole ('row' ).slice (1 );
expect (page2Rows).toHaveLength (10 );
});
});
Command Palette (cmdk) Testing
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
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
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);
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 ();
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 ();
await user.keyboard (' ' );
expect (firstTrigger).toHaveAttribute ('aria-expanded' , 'true' );
await user.keyboard ('{Enter}' );
expect (firstTrigger).toHaveAttribute ('aria-expanded' , 'false' );
});
});
E2E Tests with Playwright
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 ();
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' );
await page.keyboard .press ('Meta+k' );
await expect (page.getByPlaceholder (/type a command/i )).toBeVisible ();
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' );
await page.getByRole ('columnheader' , { name : /name/i }).click ();
const firstRow = page.getByRole ('row' ).nth (1 );
await expect (firstRow.getByRole ('cell' ).first ()).toHaveText (/^A/ );
await page.getByPlaceholder (/filter/i ).fill ('admin' );
const rows = page.getByRole ('row' );
await expect (rows).toHaveCount (3 );
});
test ('form should show validation errors and submit' , async ({ page }) => {
await page.goto ('/profile/edit' );
await page.getByRole ('button' , { name : /save/i }).click ();
await expect (page.getByText (/at least 3 characters/i )).toBeVisible ();
await page.getByLabel (/username/i ).fill ('testuser' );
await page.getByLabel (/email/i ).fill ('test@example.com' );
await page.getByRole ('button' , { name : /save/i }).click ();
await expect (page.getByText (/saved successfully/i )).toBeVisible ();
});
});
Best Practices
Use getByRole over getByTestId -- Role queries test accessibility for free; test IDs skip it.
Always use userEvent over fireEvent -- userEvent simulates real browser interactions including focus.
Wrap state changes in waitFor -- Radix components use animations; state changes may be async.
Test portal-rendered content with screen -- Dialogs and popovers render outside the component tree.
Run axe audits on interactive states -- Test accessibility of both closed and open states.
Mock next-themes for predictable theme testing -- Avoid relying on browser/OS theme preferences.
Test disabled states explicitly -- Verify that disabled buttons, inputs, and menu items are not interactive.
Use within() for scoped queries -- When testing tables or lists, scope queries to a specific row or item.
Test the complete form lifecycle -- Empty submit, validation errors, correction, successful submit.
Keep component tests focused -- Test one component behavior per test; compose for integration tests.
Anti-Patterns to Avoid
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.
Forgetting to wait for animations -- Radix uses enter/exit animations; wrap assertions in waitFor.
Testing styled-component class names -- Tailwind classes are implementation details; test visual output.
Skipping keyboard interaction tests -- Many users rely on keyboard navigation; it must work.
Mocking Radix primitives -- Never mock the component library; test the real components.
Testing only the open state -- Verify that closed/collapsed states also render correctly.
Ignoring focus management -- After a dialog closes, focus should return to the trigger element.
Not testing with screen readers -- Run automated ARIA checks and manual VoiceOver/NVDA testing.
Hardcoding animation durations in tests -- Use waitFor instead of setTimeout for animation timing.
Running Tests
npx vitest run src/components/__tests__/
npx vitest run src/components/__tests__/dialog.test.tsx
npx vitest run src/components/__tests__/ --coverage
npx vitest watch src/components/__tests__/
npx playwright test e2e/components.spec.ts
npx playwright test --ui
npx vitest run src/components/__tests__/ --reporter=verbose
npx vitest run src/components/__tests__/form.test.tsx --reporter=verbose
FormControl
<Input type ="email" placeholder ="john@example.com" {...field } />
</FormControl >
<FormMessage />
</FormItem >
<FormField
control ={form.control}
name ="bio"
render ={({ field }) =>
<FormItem >
<FormLabel >
</FormLabel >
<FormControl >
<Input placeholder ="Tell us about yourself" {...field } />
</FormControl >
<FormMessage />
</FormItem >
<Button type ="submit" >
</Button >
</form >
</Form >