← Back to DevBytes

Testing Shadcn UI Components: From Unit to E2E Tests

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles