test(web): add 57 UI component and hook tests with jsdom cleanup

Fix jsdom environment: add esbuild automatic JSX transform and
afterEach cleanup to prevent DOM leakage between tests.

Components: Badge (8), Button (13), FilterBar (5), EmptyState (8),
ConfirmDialog (8), useSelection hook (15).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:06:42 +02:00
parent 63db4a09e6
commit c0ba062460
8 changed files with 425 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "~/test-utils.js";
import { Badge } from "./Badge.js";
describe("Badge", () => {
it("renders children text", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("applies default variant classes when no variant is specified", () => {
render(<Badge>Default</Badge>);
const badge = screen.getByText("Default");
expect(badge.className).toContain("bg-gray-100");
expect(badge.className).toContain("text-gray-700");
});
it("applies success variant classes", () => {
render(<Badge variant="success">OK</Badge>);
const badge = screen.getByText("OK");
expect(badge.className).toContain("bg-green-100");
expect(badge.className).toContain("text-green-700");
});
it("applies danger variant classes", () => {
render(<Badge variant="danger">Error</Badge>);
expect(screen.getByText("Error").className).toContain("bg-red-100");
});
it("applies warning variant classes", () => {
render(<Badge variant="warning">Warn</Badge>);
expect(screen.getByText("Warn").className).toContain("bg-yellow-100");
});
it("applies info variant classes", () => {
render(<Badge variant="info">Info</Badge>);
expect(screen.getByText("Info").className).toContain("bg-blue-100");
});
it("merges custom className", () => {
render(<Badge className="custom-class">Test</Badge>);
expect(screen.getByText("Test").className).toContain("custom-class");
});
it("renders as a span element", () => {
render(<Badge>Span</Badge>);
expect(screen.getByText("Span").tagName).toBe("SPAN");
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button.js";
describe("Button", () => {
it("renders children text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies primary variant by default", () => {
render(<Button>Primary</Button>);
expect(screen.getByRole("button").className).toContain("bg-brand-600");
});
it("applies secondary variant", () => {
render(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole("button").className).toContain("border-gray-300");
});
it("applies ghost variant", () => {
render(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole("button").className).toContain("hover:bg-gray-100");
});
it("applies danger variant", () => {
render(<Button variant="danger">Delete</Button>);
expect(screen.getByRole("button").className).toContain("bg-red-600");
});
it("applies small size", () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole("button").className).toContain("px-3");
expect(screen.getByRole("button").className).toContain("text-xs");
});
it("applies medium size by default", () => {
render(<Button>Medium</Button>);
expect(screen.getByRole("button").className).toContain("px-4");
expect(screen.getByRole("button").className).toContain("text-sm");
});
it("applies large size", () => {
render(<Button size="lg">Large</Button>);
expect(screen.getByRole("button").className).toContain("px-6");
expect(screen.getByRole("button").className).toContain("text-base");
});
it("handles click events", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledOnce();
});
it("disables the button when disabled prop is true", () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("does not fire click when disabled", async () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Disabled
</Button>,
);
await userEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});
it("merges custom className", () => {
render(<Button className="extra">Styled</Button>);
expect(screen.getByRole("button").className).toContain("extra");
});
it("passes through additional HTML attributes", () => {
render(
<Button type="submit" data-testid="submit-btn">
Submit
</Button>,
);
expect(screen.getByTestId("submit-btn")).toHaveAttribute("type", "submit");
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { ConfirmDialog } from "./ConfirmDialog.js";
describe("ConfirmDialog", () => {
const defaultProps = {
title: "Delete item?",
message: "This action cannot be undone.",
onConfirm: vi.fn(),
onCancel: vi.fn(),
};
it("renders title and message", () => {
render(<ConfirmDialog {...defaultProps} />);
expect(screen.getByText("Delete item?")).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
});
it("uses default button labels", () => {
render(<ConfirmDialog {...defaultProps} />);
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("uses custom button labels", () => {
render(<ConfirmDialog {...defaultProps} confirmLabel="Yes, delete" cancelLabel="No, keep" />);
expect(screen.getByRole("button", { name: "Yes, delete" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "No, keep" })).toBeInTheDocument();
});
it("calls onConfirm when confirm button is clicked", async () => {
const onConfirm = vi.fn();
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(onConfirm).toHaveBeenCalledOnce();
});
it("calls onCancel when cancel button is clicked", async () => {
const onCancel = vi.fn();
render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledOnce();
});
it("applies danger styling to confirm button when variant is danger", () => {
render(<ConfirmDialog {...defaultProps} variant="danger" />);
const confirmBtn = screen.getByRole("button", { name: "Confirm" });
expect(confirmBtn.className).toContain("bg-red-600");
});
it("applies default styling to confirm button when variant is default", () => {
render(<ConfirmDialog {...defaultProps} variant="default" />);
const confirmBtn = screen.getByRole("button", { name: "Confirm" });
expect(confirmBtn.className).toContain("bg-brand-600");
});
it("calls onCancel when clicking the backdrop", async () => {
const onCancel = vi.fn();
const { container } = render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
const backdrop = container.firstElementChild!;
await userEvent.click(backdrop);
expect(onCancel).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { EmptyState } from "./EmptyState.js";
describe("EmptyState", () => {
it("renders the title", () => {
render(<EmptyState title="No results" />);
expect(screen.getByText("No results")).toBeInTheDocument();
});
it("renders detail text when provided", () => {
render(<EmptyState title="Empty" detail="Try adjusting your filters" />);
expect(screen.getByText("Try adjusting your filters")).toBeInTheDocument();
});
it("does not render detail when not provided", () => {
const { container } = render(<EmptyState title="Empty" />);
const paragraphs = container.querySelectorAll("p");
expect(paragraphs).toHaveLength(1);
});
it("renders an action button when action is provided", () => {
const action = { label: "Create new", onClick: vi.fn() };
render(<EmptyState title="Empty" action={action} />);
expect(screen.getByRole("button", { name: "Create new" })).toBeInTheDocument();
});
it("calls action onClick when button is clicked", async () => {
const onClick = vi.fn();
render(<EmptyState title="Empty" action={{ label: "Add", onClick }} />);
await userEvent.click(screen.getByRole("button", { name: "Add" }));
expect(onClick).toHaveBeenCalledOnce();
});
it("does not render action button when not provided", () => {
render(<EmptyState title="Empty" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders icon when provided", () => {
render(<EmptyState title="Empty" icon={<span data-testid="icon">Icon</span>} />);
expect(screen.getByTestId("icon")).toBeInTheDocument();
});
it("applies testId to the container", () => {
render(<EmptyState title="Empty" testId="empty-state" />);
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { FilterBar } from "./FilterBar.js";
describe("FilterBar", () => {
it("renders children", () => {
render(
<FilterBar>
<span>Filter content</span>
</FilterBar>,
);
expect(screen.getByText("Filter content")).toBeInTheDocument();
});
it("does not show clear button when no active filters", () => {
render(
<FilterBar hasActiveFilters={false} onClearFilters={vi.fn()}>
<span>Filters</span>
</FilterBar>,
);
expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
});
it("does not show clear button when onClearFilters is not provided", () => {
render(
<FilterBar hasActiveFilters>
<span>Filters</span>
</FilterBar>,
);
expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
});
it("shows clear button when hasActiveFilters and onClearFilters are both provided", () => {
render(
<FilterBar hasActiveFilters onClearFilters={vi.fn()}>
<span>Filters</span>
</FilterBar>,
);
expect(screen.getByText("Clear filters")).toBeInTheDocument();
});
it("calls onClearFilters when clear button is clicked", async () => {
const onClear = vi.fn();
render(
<FilterBar hasActiveFilters onClearFilters={onClear}>
<span>Filters</span>
</FilterBar>,
);
await userEvent.click(screen.getByText("Clear filters"));
expect(onClear).toHaveBeenCalledOnce();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSelection } from "./useSelection.js";
describe("useSelection", () => {
it("starts with empty selection", () => {
const { result } = renderHook(() => useSelection());
expect(result.current.count).toBe(0);
expect(result.current.selectedArray).toEqual([]);
});
it("toggles an item on", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggle("a"));
expect(result.current.selectedIds.has("a")).toBe(true);
expect(result.current.count).toBe(1);
});
it("toggles an item off", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggle("a"));
act(() => result.current.toggle("a"));
expect(result.current.selectedIds.has("a")).toBe(false);
expect(result.current.count).toBe(0);
});
it("toggleAll selects all items when none are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggleAll(["a", "b", "c"]));
expect(result.current.count).toBe(3);
expect(result.current.selectedIds.has("a")).toBe(true);
expect(result.current.selectedIds.has("b")).toBe(true);
expect(result.current.selectedIds.has("c")).toBe(true);
});
it("toggleAll deselects all items when all are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggleAll(["a", "b"]));
act(() => result.current.toggleAll(["a", "b"]));
expect(result.current.count).toBe(0);
});
it("toggleAll selects remaining items when some are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggle("a"));
act(() => result.current.toggleAll(["a", "b", "c"]));
expect(result.current.count).toBe(3);
});
it("clear removes all selections", () => {
const { result } = renderHook(() => useSelection());
act(() => {
result.current.toggle("a");
result.current.toggle("b");
});
act(() => result.current.clear());
expect(result.current.count).toBe(0);
});
it("isAllSelected returns true when all given ids are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggleAll(["a", "b"]));
expect(result.current.isAllSelected(["a", "b"])).toBe(true);
});
it("isAllSelected returns false when some ids are not selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggle("a"));
expect(result.current.isAllSelected(["a", "b"])).toBe(false);
});
it("isAllSelected returns false for empty ids array", () => {
const { result } = renderHook(() => useSelection());
expect(result.current.isAllSelected([])).toBe(false);
});
it("isIndeterminate returns true when some but not all are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggle("a"));
expect(result.current.isIndeterminate(["a", "b"])).toBe(true);
});
it("isIndeterminate returns false when all are selected", () => {
const { result } = renderHook(() => useSelection());
act(() => result.current.toggleAll(["a", "b"]));
expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
});
it("isIndeterminate returns false when none are selected", () => {
const { result } = renderHook(() => useSelection());
expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
});
it("selectedArray reflects the current selection", () => {
const { result } = renderHook(() => useSelection());
act(() => {
result.current.toggle("x");
result.current.toggle("y");
});
expect(result.current.selectedArray.sort()).toEqual(["x", "y"]);
});
it("preserves other selections when toggling individual items", () => {
const { result } = renderHook(() => useSelection());
act(() => {
result.current.toggle("a");
result.current.toggle("b");
});
act(() => result.current.toggle("a"));
expect(result.current.selectedIds.has("b")).toBe(true);
expect(result.current.count).toBe(1);
});
});
+6
View File
@@ -1 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+3
View File
@@ -2,6 +2,9 @@ import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: {
jsx: "automatic",
},
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),