test(web): unit tests for ThemeToggle (8 tests)
Mocks next-themes' useTheme so the component is testable in Node.
Mocks the radix DropdownMenu primitives to render trigger + items
inline instead of through a portal. Coverage:
- Rendered markup picks the correct icon + label for each theme
('light' → Sun, 'dark' → Moon, 'system' / undefined → Monitor).
- All three menu items render under the trigger.
- Each menu item's onClick calls setTheme with the matching value.
Walks the React element tree to grab the onClick handlers without
needing a DOM — keeps the existing react-dom/server testing setup.
Total tests: 92.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6f4e3b2e5
commit
2b71ebeb17
149
apps/web/src/components/theme-toggle.test.tsx
Normal file
149
apps/web/src/components/theme-toggle.test.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// next-themes accesses window/localStorage on import; stub the hook so the
|
||||
// component is testable in a Node environment.
|
||||
const setThemeMock = vi.fn();
|
||||
const useThemeReturn: { theme: string | undefined; setTheme: typeof setThemeMock } = {
|
||||
theme: "system",
|
||||
setTheme: setThemeMock,
|
||||
};
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => useThemeReturn,
|
||||
}));
|
||||
|
||||
// Radix DropdownMenu uses portals + client refs. Render trigger and items
|
||||
// inline so we can assert on the markup deterministically.
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} data-testid="dropdown-item">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
function setTheme(t: string | undefined) {
|
||||
useThemeReturn.theme = t;
|
||||
}
|
||||
|
||||
describe("ThemeToggle — rendered markup", () => {
|
||||
beforeEach(() => {
|
||||
setThemeMock.mockReset();
|
||||
setTheme("system");
|
||||
});
|
||||
|
||||
it("shows the system label + monitor icon when theme is 'system'", () => {
|
||||
setTheme("system");
|
||||
const html = renderToStaticMarkup(<ThemeToggle />);
|
||||
expect(html).toMatch(/lucide-monitor/);
|
||||
expect(html).toContain(">system<");
|
||||
});
|
||||
|
||||
it("shows the light label + sun icon when theme is 'light'", () => {
|
||||
setTheme("light");
|
||||
const html = renderToStaticMarkup(<ThemeToggle />);
|
||||
expect(html).toMatch(/lucide-sun/);
|
||||
expect(html).toContain(">light<");
|
||||
});
|
||||
|
||||
it("shows the dark label + moon icon when theme is 'dark'", () => {
|
||||
setTheme("dark");
|
||||
const html = renderToStaticMarkup(<ThemeToggle />);
|
||||
expect(html).toMatch(/lucide-moon/);
|
||||
expect(html).toContain(">dark<");
|
||||
});
|
||||
|
||||
it("falls back to 'system' label when next-themes returns undefined (pre-mount)", () => {
|
||||
setTheme(undefined);
|
||||
const html = renderToStaticMarkup(<ThemeToggle />);
|
||||
expect(html).toContain(">system<");
|
||||
});
|
||||
|
||||
it("renders three menu items (Light / Dark / System)", () => {
|
||||
setTheme("system");
|
||||
const html = renderToStaticMarkup(<ThemeToggle />);
|
||||
expect(html).toContain("Light");
|
||||
expect(html).toContain("Dark");
|
||||
expect(html).toContain("System");
|
||||
// One trigger + three menu items.
|
||||
const items = html.match(/data-testid="dropdown-item"/g) ?? [];
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeToggle — onClick wires through to setTheme", () => {
|
||||
beforeEach(() => {
|
||||
setThemeMock.mockReset();
|
||||
setTheme("system");
|
||||
});
|
||||
|
||||
// We can't realistically click a server-rendered button. Instead reach
|
||||
// into the rendered React tree and grab the onClick handlers — that's
|
||||
// exactly what the click handler will fire in the browser, so it's a
|
||||
// faithful contract check.
|
||||
function getMenuItemHandlers(): Array<() => void> {
|
||||
// Render via React.createElement to keep the element tree.
|
||||
// Then walk it for the dropdown-item buttons.
|
||||
// (Easier than parsing the SSR markup.)
|
||||
const el = (<ThemeToggle />) as unknown as React.ReactElement;
|
||||
const handlers: Array<() => void> = [];
|
||||
function visit(node: unknown): void {
|
||||
if (!node) return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const c of node) visit(c);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object") return;
|
||||
const n = node as { props?: Record<string, unknown>; type?: unknown };
|
||||
const props = n.props ?? {};
|
||||
if (typeof props.onClick === "function" && props["data-testid"] === "dropdown-item") {
|
||||
handlers.push(props.onClick as () => void);
|
||||
}
|
||||
// type can be a function component — render it once with its props
|
||||
// so we can recurse into its output. Component functions used here
|
||||
// (DropdownMenuItem mocks etc.) are pure render-children.
|
||||
if (typeof n.type === "function") {
|
||||
const rendered = (n.type as (p: unknown) => unknown)(props);
|
||||
visit(rendered);
|
||||
}
|
||||
if ("children" in props) {
|
||||
visit(props.children);
|
||||
}
|
||||
}
|
||||
visit(el);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
it("first menu item sets theme to 'light'", () => {
|
||||
const [light] = getMenuItemHandlers();
|
||||
light?.();
|
||||
expect(setThemeMock).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
it("second menu item sets theme to 'dark'", () => {
|
||||
const [, dark] = getMenuItemHandlers();
|
||||
dark?.();
|
||||
expect(setThemeMock).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("third menu item sets theme to 'system'", () => {
|
||||
const [, , system] = getMenuItemHandlers();
|
||||
system?.();
|
||||
expect(setThemeMock).toHaveBeenCalledWith("system");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user