Introduction to Testing Shadcn UI Components
Shadcn UI has become one of the most popular component libraries in the React ecosystem. Unlike traditional libraries installed via npm, Shadcn UI gives you direct ownership of the component source code, copied into your project. This ownership model means you are fully responsible for maintaining and testing these components. In this tutorial, we will explore a comprehensive testing strategy that spans from isolated unit tests to full end-to-end (E2E) tests, ensuring your Shadcn UI components behave correctly at every level.
What Is Shadcn UI?
Shadcn UI is a collection of reusable, accessible components built on top of Radix UI primitives and styled with Tailwind CSS. Instead of installing a package, you add components to your codebase using a CLI tool. This approach gives you complete control over the markup, styles, and behavior of each component.
Why Testing Shadcn UI Components Matters
Because Shadcn UI components live inside your repository, they become part of your application code. Any modifications you make, whether to styling, accessibility attributes, or business logic, can introduce regressions. A robust testing strategy helps you:
- Catch visual and functional regressions early
- Verify accessibility behavior inherited from Radix UI primitives
- Ensure custom modifications do not break user interactions
- Document expected component behavior for your team
- Maintain confidence when upgrading dependencies
Setting Up the Testing Environment
Before writing tests, we need to configure the proper tooling. For a modern React project using Shadcn UI, we recommend Vitest for unit and integration tests, Testing Library for DOM interactions, and Playwright for E2E tests.
Installing Dependencies
Run the following commands to install the testing stack:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
npm install -D @playwright/test
Configuring Vitest
Create a vitest.config.ts file at the root of your project. This configuration sets up the jsdom environment, enables global test APIs, and configures path aliases to match your TypeScript setup:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./test/setup.ts"],
css: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Creating the Test Setup File
The setup file imports the custom Jest DOM matchers and ensures tests clean up after each run. Create test/setup.ts:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Configuring Playwright
Initialize Playwright with npx playwright init and then customize the generated playwright.config.ts:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Unit Testing Shadcn UI Components
Unit tests verify that a component renders correctly and responds to user interactions in isolation. For Shadcn UI components, unit tests typically focus on rendering output, prop forwarding, and basic interactions.
Testing the Button Component
Let us start with the most fundamental component: the Button. Shadcn UI's Button is built with the class-variance-authority library and supports multiple variants and sizes. Here is a typical implementation:
// src/components/ui/button.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Now let us write comprehensive unit tests for this component:
// src/components/ui/button.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/ui/button";
describe("Button", () => {
it("renders with default variant and size", () => {
render(<Button>Click me</Button>);
const button = screen.getByRole("button", { name: /click me/i });
expect(button).toBeInTheDocument();
});
it("applies the correct classes for the destructive variant", () => {
render(<Button variant="destructive">Delete</Button>);
const button = screen.getByRole("button", { name: /delete/i });
expect(button.className).toContain("bg-destructive");
expect(button.className).toContain("text-destructive-foreground");
});
it("applies the correct classes for the sm size", () => {
render(<Button size="sm">Small</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("h-9");
expect(button.className).toContain("px-3");
});
it("forwards additional props to the underlying button element", () => {
render(<Button data-testid="custom-button" aria-label="Save">Save</Button>);
const button = screen.getByTestId("custom-button");
expect(button).toHaveAttribute("aria-label", "Save");
});
it("handles click events", async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Submit</Button>);
const button = screen.getByRole("button", { name: /submit/i });
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("does not fire click events when disabled", async () => {
const handleClick = vi.fn();
render(
<Button onClick={handleClick} disabled>
Disabled
</Button>
);
const button = screen.getByRole("button", { name: /disabled/i });
await userEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
expect(button).toBeDisabled();
});
it("forwards the ref to the button element", () => {
const ref = vi.fn();
render(<Button ref={ref}>Ref Test</Button>);
expect(ref).toHaveBeenCalled();
});
});
Testing the Input Component
The Input component is a styled wrapper around the native input element. Testing it involves verifying that it forwards props correctly and applies the expected styles:
// src/components/ui/input.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Input } from "@/components/ui/input";
describe("Input", () => {
it("renders an input element", () => {
render(<Input />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("accepts and displays typed text", async () => {
render(<Input placeholder="Enter name" />);
const input = screen.getByPlaceholderText("Enter name");
await userEvent.type(input, "John Doe");
expect(input).toHaveValue("John Doe");
});
it("supports the disabled state", () => {
render(<Input disabled />);
expect(screen.getByRole("textbox")).toBeDisabled();
});
it("forwards custom props", () => {
render(<Input type="email" data-testid="email-input" />);
const input = screen.getByTestId("email-input");
expect(input).toHaveAttribute("type", "email");
});
});
Integration Testing Complex Components
Integration tests verify that multiple components work together correctly. Shadcn UI includes complex components like Dialog, DropdownMenu, Select, and Combobox that rely on Radix UI primitives. These components involve portals, focus management, and keyboard navigation, making integration testing essential.
Testing the Dialog Component
The Dialog component uses Radix UI's Dialog primitive under the hood. It renders content in a portal, manages focus trapping, and supports keyboard interactions. Here is how to test it:
// src/components/ui/dialog.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
describe("Dialog", () => {
it("does not render content when closed", () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Title</DialogTitle>
<DialogDescription>Test Description</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
expect(screen.getByText("Open")).toBeInTheDocument();
});
it("opens dialog when trigger is clicked", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Title</DialogTitle>
<DialogDescription>Test Description</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
await user.click(screen.getByText("Open"));
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
it("closes dialog when close button is clicked", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Title</DialogTitle>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
await user.click(screen.getByText("Open"));
expect(screen.getByText("Test Title")).toBeInTheDocument();
await user.click(screen.getByText("Cancel"));
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
it("closes dialog when Escape key is pressed", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Title</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
);
await user.click(screen.getByText("Open"));
expect(screen.getByText("Test Title")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
});
Testing the Select Component
The Select component is another complex component that involves a trigger, a popover, and a list of options. Testing it requires simulating user interactions and verifying the selected value:
// src/components/ui/select.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const SelectWrapper = ({ onChange }: { onChange?: (value: string) => void }) => (
<Select onValueChange={onChange}>
<SelectTrigger data-testid="select-trigger">
<SelectValue placeholder="Choose a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
);
describe("Select", () => {
it("renders the trigger with placeholder text", () => {
render(<SelectWrapper />);
expect(screen.getByText("Choose a fruit")).toBeInTheDocument();
});
it("opens the dropdown and displays options when clicked", async () => {
const user = userEvent.setup();
render(<SelectWrapper />);
await user.click(screen.getByTestId("select-trigger"));
expect(screen.getByText("Apple")).toBeInTheDocument();
expect(screen.getByText("Banana")).toBeInTheDocument();
expect(screen.getByText("Cherry")).toBeInTheDocument();
});
it("selects an option and updates the trigger display", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<SelectWrapper onChange={handleChange} />);
await user.click(screen.getByTestId("select-trigger"));
await user.click(screen.getByText("Banana"));
expect(handleChange).toHaveBeenCalledWith("banana");
expect(screen.getByTestId("select-trigger")).toHaveTextContent("Banana");
});
it("supports keyboard navigation with arrow keys", async () => {
const user = userEvent.setup();
render(<SelectWrapper />);
const trigger = screen.getByTestId("select-trigger");
trigger.focus();
await user.keyboard("{Enter}");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(trigger).toHaveTextContent("Apple");
});
});
Testing a Form with Multiple Components
In real applications, Shadcn UI components are combined into forms. Here is an integration test for a login form that uses Input, Button, and Label components together:
// src/components/login-form.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "@/components/login-form";
describe("LoginForm", () => {
it("submits with valid credentials", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "securePassword123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "securePassword123",
});
});
});
it("shows validation errors for empty fields", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
});
End-to-End Testing with Playwright
While unit and integration tests verify component behavior in isolation, E2E tests validate the full user journey through a real browser. Playwright is an excellent choice for testing Shadcn UI components in the context of a running application.
Testing a Settings Page with Tabs
Suppose you have a settings page that uses the Tabs component to switch between Profile, Account, and Notifications panels. Here is an E2E test that verifies the tab switching behavior:
// e2e/settings.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Settings Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/settings");
});
test("displays the Profile tab by default", async ({ page }) => {
await expect(page.getByRole("tab", { name: /profile/i })).toHaveAttribute(
"data-state",
"active"
);
await expect(page.getByText("Profile Information")).toBeVisible();
});
test("switches to the Account tab when clicked", async ({ page }) => {
await page.getByRole("tab", { name: /account/i }).click();
await expect(page.getByRole("tab", { name: /account/i })).toHaveAttribute(
"data-state",
"active"
);
await expect(page.getByText("Account Settings")).toBeVisible();
await expect(page.getByText("Profile Information")).not.toBeVisible();
});
test("switches to the Notifications tab when clicked", async ({ page }) => {
await page.getByRole("tab", { name: /notifications/i }).click();
await expect(page.getByText("Notification Preferences")).toBeVisible();
});
test("supports keyboard navigation between tabs", async ({ page }) => {
await page.getByRole("tab", { name: /profile/i }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: /account/i })).toHaveAttribute(
"data-state",
"active"
);
});
});
Testing a Dialog Workflow
Here is an E2E test that verifies a delete confirmation dialog workflow on a dashboard page:
// e2e/delete-user.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Delete User Workflow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/users");
});
test("opens confirmation dialog and deletes a user", async ({ page }) => {
// Verify the user exists in the list
await expect(page.getByText("john@example.com")).toBeVisible();
// Click the delete button for that user
await page
.getByRole("row", { name: /john@example.com/ })
.getByRole("button", { name: /delete/i })
.click();
// Verify the dialog appears
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page.getByText(/are you sure you want to delete this user/i)
).toBeVisible();
// Confirm deletion
await page.getByRole("button", { name: /confirm/i }).click();
// Verify the dialog closes and the user is removed
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByText("john@example.com")).not.toBeVisible();
await expect(page.getByText(/user deleted successfully/i)).toBeVisible();
});
test("cancels deletion when Cancel button is clicked", async ({ page }) => {
await page
.getByRole("row", { name: /john@example.com/ })
.getByRole("button", { name: /delete/i })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByRole("button", { name: /cancel/i }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByText("john@example.com")).toBeVisible();
});
test("closes dialog with Escape key", async ({ page }) => {
await page
.getByRole("row", { name: /john@example.com/ })
.getByRole("button", { name: /delete/i })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).not.toBeVisible();
});
});
Testing a Combobox with Search
The Combobox component combines a popover with a search input. Testing it with Playwright ensures the search filtering works in a real browser environment:
// e2e/combobox.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Country Selector Combobox", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/registration");
});
test("filters countries based on search input", async ({ page }) => {
await page.getByRole("combobox").click();
// Type a partial search query
await page.getByRole("textbox", { name: /search/i }).fill("Ger");
// Verify only matching results are shown
await expect(page.getByRole("option", { name: "Germany" })).toBeVisible();
await expect(page.getByRole("option", { name: "France" })).not.toBeVisible();
});
test("selects a country and displays it in the trigger", async ({ page }) => {
await page.getByRole("combobox").click();
await page.getByRole("option", { name: "Japan" }).click();
await expect(page.getByRole("combobox")).toHaveText("Japan");
});
test("shows empty state when no results match", async ({ page }) => {
await page.getByRole("combobox").click();
await page.getByRole("textbox", { name: /search/i }).fill("XYZ");
await expect(page.getByText(/no results found/i)).toBeVisible();
});
});
Testing Accessibility
Shadcn UI components are built on Radix UI, which provides strong accessibility guarantees out of the box. However, your custom compositions and modifications can introduce accessibility issues. You should test for ARIA attributes, keyboard navigation, and screen reader compatibility.
Using jest-axe for Automated Accessibility Testing
Install jest-axe and @axe-core/playwright to run accessibility audits in both unit and E2E tests:
npm install -D jest-axe
npm install -D @axe-core/playwright
Here is a unit test that checks the accessibility of a Dialog component:
// src/components/ui/dialog.a11y.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "vitest-axe";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
describe("Dialog Accessibility", () => {
it("has no accessibility violations when open", async () => {
const user = userEvent.setup();
const { container } = render(
<Dialog defaultOpen>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Adjust your account preferences here.
</DialogDescription>
</DialogHeader>
<p>Dialog body content</p>
</DialogContent>
</Dialog>
);
const results = await axe(container);
expect(results.violations).toEqual([]);
});
it("traps focus within the dialog when open", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Description text</DialogDescription>
</DialogHeader>
<button>First Action</button>
<button>Second Action</button>
</DialogContent>
</Dialog>
);
await user.click(screen.getByText("Open"));
// Focus should start on an element within the dialog
const firstButton = screen.getByText("First Action");
const secondButton = screen.getByText("Second Action");
await user.tab();
await user.tab();
// After tabbing through all elements, focus should wrap back
expect(document.activeElement).not.toBe(document.body);
});
});
Accessibility Testing in Playwright
For E2E accessibility testing, inject the axe-core library into your Playwright tests:
// e2e/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Accessibility audits", () => {
test("home page has no critical violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"])
.analyze();
expect(results.violations.filter(v => v.impact === "critical")).toEqual([]);
});
test("settings page passes accessibility checks", async ({ page }) => {
await page.goto("/settings");
const results = await new AxeBuilder({ page })
.exclude("[data-radix-popper-content-wrapper]")
.analyze();
const criticalViolations = results.violations.filter(
v => v.impact === "critical" || v.impact === "serious"
);
expect(criticalViolations).toEqual([]);
});
});
Visual Regression Testing
Visual regression testing catches unintended style changes. Since Shadcn UI components are styled with Tailwind CSS, even small class changes can alter the visual appearance. Playwright's screenshot comparison feature is perfect for this.
// e2e/visual-regression.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Visual regression", () => {
test("button variants match baseline", async ({ page }) => {
await page.goto("/storybook/button");
for (const variant of ["default", "destructive", "outline", "secondary", "ghost"]) {
const button = page.getByTestId(`button-${variant}`);
await expect(button).toHaveScreenshot(`button-${variant}.png`);
}
});
test("dialog appearance matches baseline", async ({ page }) => {
await page.goto("/storybook/dialog");
await page.getByRole("button", { name: /open dialog/i }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toHaveScreenshot("dialog-open.png", {
maxDiffPixelRatio: 0.01,
});
});
test("dark mode renders correctly", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
await expect(page).toHaveScreenshot("homepage-dark.png");
});
});
Best Practices for Testing Shadcn UI Components
Test Behavior, Not Implementation
Avoid asserting on internal class names or DOM structure that may change when you update a component. Instead, test what the user sees and does. Use semantic queries like getByRole, getByLabelText, and getByText rather than getByTestId whenever possible.
Use User Event Over FireEvent
The userEvent library from Testing Library simulates real browser behavior more accurately than fireEvent. It dispatches events in the proper order, handles focus management, and respects pointer events. Always prefer userEvent.click() over fireEvent.click().
Test Keyboard Navigation
Radix UI components are designed to be fully keyboard accessible. Your tests should verify that keyboard interactions work correctly. Test Tab navigation, Enter and Space activation, Escape to close, and Arrow key navigation where applicable.
Mock External Dependencies
When testing components that fetch data or interact with external services, mock those dependencies to keep tests fast and deterministic. Use tools like MSW (Mock Service Worker) for API mocking:
npm install -D msw
// test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
];
Organize Tests by Component and Test Type
Maintain a clear file structure that separates unit, integration, and E2E tests. A recommended structure looks like this:
src/
components/
ui/
button.tsx
button.test.tsx
dialog.tsx
dialog.test.tsx
login-form.tsx
login-form.test.tsx
e2e/
settings.spec.ts
delete-user.spec.ts
accessibility.spec.ts
visual-regression.spec.ts
Run Tests in CI
Integrate your test suite into your CI pipeline. Here is a GitHub Actions workflow example:
name: CI
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:unit
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
Keep Tests Fast and Isolated
Each test should be independent and not rely on the state of another test. Use beforeEach and afterEach hooks to set up and tear down test conditions. For E2E tests, reset the database or application state between tests to avoid flaky results.
Snapshot Test Sparingly
While snapshot tests can catch unexpected changes, they are often brittle and produce large diffs that are hard to review. Use them only for stable, simple components and prefer explicit assertions for complex components. If you do use snapshots, review them carefully during code review.
Conclusion
Testing Shadcn UI components requires a multi-layered approach that addresses the unique characteristics of this library. Because you own the component source code, you have the freedom and responsibility to test both the default behavior and any custom modifications you make. Unit tests with Vitest and Testing Library verify individual component rendering and interactions. Integration tests ensure that complex components like Dialog, Select, and Combobox work correctly with their Radix UI primitives, including focus management and keyboard navigation. E2E tests with Playwright validate complete user workflows in a real browser, while accessibility audits with axe-core ensure your components remain usable by everyone. Visual regression tests catch unintended style changes that can slip through functional tests. By combining all these testing strategies and following best practices like testing behavior over implementation, using realistic user interactions, and integrating tests into your CI pipeline, you can maintain a robust and reliable component library that gives your team confidence to iterate quickly without introducing regressions.