chore: add unit tests

This commit is contained in:
Lluis Agusti
2025-06-25 20:00:03 +04:00
parent 7c3e8ec221
commit 4e817c8d8a
16 changed files with 1661 additions and 643 deletions

View File

@@ -60,9 +60,11 @@ Every time a new Front-end dependency is added by you or others, you will need t
- `pnpm start` - Start production server
- `pnpm lint` - Run ESLint and Prettier checks
- `pnpm format` - Format code with Prettier
- `pnpm type-check` - Run TypeScript type checking
- `pnpm types` - Run TypeScript type checking
- `pnpm test` - Run Playwright tests
- `pnpm test-ui` - Run Playwright tests with UI
- `pnpm test:ui` - Run Playwright tests with UI
- `pnpm test:unit` - Run unit tests (Vitest)
- `pnpm test:unit:watch` - Run unit tests (Vitest) in watch mode
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
@@ -96,14 +98,14 @@ Storybook is a powerful development environment for UI components. It allows you
To build a static version of Storybook for deployment, use:
```bash
pnpm build-storybook
pnpm build:storybook
```
3. **Running Storybook Tests**:
Storybook tests can be run using:
```bash
pnpm test-storybook
pnpm test:storybook
```
4. **Writing Stories**:

View File

@@ -10,14 +10,14 @@
"start:standalone": "cd .next/standalone && node server.js",
"lint": "next lint && prettier --check .",
"format": "prettier --write .",
"type-check": "tsc --noEmit",
"types": "tsc --noEmit",
"test": "next build --turbo && playwright test",
"test-ui": "next build --turbo && playwright test --ui",
"test:no-build": "playwright test",
"test:ui": "next build --turbo && playwright test --ui",
"test:unit": "vitest --config vitest.config.mjs --run",
"test:unit:watch": "vitest --config vitest.config.mjs --watch",
"gentests": "playwright codegen http://localhost:3000",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"build:storybook": "storybook build",
"fetch:openapi": "curl http://localhost:8006/openapi.json > ./src/api/openapi.json && prettier --write ./src/api/openapi.json",
"generate:api-client": "orval --config ./orval.config.ts",
"generate:api-all": "pnpm run fetch:openapi && pnpm run generate:api-client"
@@ -102,6 +102,9 @@
"@storybook/nextjs": "9.0.12",
"@tanstack/eslint-plugin-query": "5.78.0",
"@tanstack/react-query-devtools": "5.80.10",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/canvas-confetti": "1.9.0",
"@types/lodash": "4.17.18",
"@types/negotiator": "0.6.4",
@@ -109,6 +112,7 @@
"@types/react": "18.3.17",
"@types/react-dom": "18.3.5",
"@types/react-modal": "3.16.3",
"@vitest/browser": "3.2.4",
"axe-playwright": "2.1.0",
"chromatic": "11.25.2",
"concurrently": "9.1.2",
@@ -116,6 +120,7 @@
"eslint-config-next": "15.3.4",
"eslint-plugin-storybook": "9.0.12",
"import-in-the-middle": "1.14.2",
"jsdom": "26.1.0",
"msw": "2.10.2",
"msw-storybook-addon": "2.0.5",
"orval": "7.10.0",
@@ -125,7 +130,9 @@
"require-in-the-middle": "7.5.2",
"storybook": "9.0.12",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
"typescript": "5.8.3",
"vite": "7.0.0",
"vitest": "3.2.4"
},
"msw": {
"workerDirectory": [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";
describe("Button Component", () => {
it("renders button with text", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" }),
).toBeInTheDocument();
});
it("calls onClick when clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Disabled button</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("does not call onClick when disabled", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Button disabled onClick={handleClick}>
Disabled button
</Button>,
);
// Try to click the disabled button
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
it("renders with different variants", () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-zinc-800");
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-zinc-100");
rerender(<Button variant="destructive">Destructive</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-red-500");
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByRole("button")).toHaveClass("border-zinc-700");
rerender(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-transparent");
});
// Sizes and variants styling are covered by Chromatic via Storybook
it("shows loading state", () => {
render(<Button loading>Loading button</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("pointer-events-none");
// Check for loading spinner (svg element)
const spinner = button.querySelector("svg");
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass("animate-spin");
});
it("renders with left icon", () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>;
render(<Button leftIcon={<TestIcon />}>Button with left icon</Button>);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("Button with left icon")).toBeInTheDocument();
});
it("renders with right icon", () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>;
render(<Button rightIcon={<TestIcon />}>Button with right icon</Button>);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("Button with right icon")).toBeInTheDocument();
});
it("supports keyboard navigation", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Keyboard button</Button>);
const button = screen.getByRole("button");
// Focus the button
await user.tab();
expect(button).toHaveFocus();
// Press Enter
await user.keyboard("{Enter}");
expect(handleClick).toHaveBeenCalledTimes(1);
// Press Space
await user.keyboard(" ");
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("applies custom className", () => {
render(<Button className="custom-class">Custom button</Button>);
expect(screen.getByRole("button")).toHaveClass("custom-class");
});
it("handles double click", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Double click me</Button>);
await user.dblClick(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("maintains focus after click", async () => {
const user = userEvent.setup();
render(<Button>Focus test</Button>);
const button = screen.getByRole("button");
await user.click(button);
expect(button).toHaveFocus();
});
});

View File

@@ -1,275 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { Play, Plus } from "lucide-react";
import { expect, fn, userEvent, within } from "storybook/test";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
title: "Atoms/Tests/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["!autodocs"],
args: {
onClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// Test button click functionality
export const ClickInteraction: Story = {
args: {
children: "Click Me",
variant: "primary",
},
play: async function testButtonClick({ args, canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Click Me" });
// Test initial state
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
// Test click interaction
await userEvent.click(button);
// Assert the click handler was called
expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
// Test disabled button behavior
export const DisabledInteraction: Story = {
args: {
children: "Disabled Button",
variant: "primary",
disabled: true,
},
play: async function testDisabledButton({ canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Disabled Button" });
// Test disabled state
expect(button).toBeDisabled();
expect(button).toHaveAttribute("disabled");
// Test that disabled button has proper styling (pointer-events: none prevents clicking)
// We don't test clicking because disabled buttons with pointer-events: none can't be clicked
},
};
// Test loading button behavior
export const LoadingInteraction: Story = {
args: {
children: "Loading Button",
variant: "primary",
loading: true,
},
play: async function testLoadingButton({ canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Loading Button" });
// Test loading state - button should show loading spinner
const spinner = button.querySelector("svg");
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass("animate-spin");
// Test that loading button is still clickable but pointer events are disabled
expect(button).toHaveClass("pointer-events-none");
},
};
// Test keyboard navigation
export const KeyboardInteraction: Story = {
args: {
children: "Keyboard Test",
variant: "primary",
},
play: async function testKeyboardNavigation({ args, canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Keyboard Test" });
// Test tab navigation
await userEvent.tab();
expect(button).toHaveFocus();
// Test Enter key activation
await userEvent.keyboard("{Enter}");
expect(args.onClick).toHaveBeenCalledTimes(1);
// Test Space key activation
await userEvent.keyboard(" ");
expect(args.onClick).toHaveBeenCalledTimes(2);
},
};
// Test focus and blur events
export const FocusInteraction: Story = {
args: {
children: "Focus Test",
variant: "outline",
},
play: async function testFocusEvents({ canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Focus Test" });
// Test programmatic focus
button.focus();
expect(button).toHaveFocus();
// Test blur
button.blur();
expect(button).not.toHaveFocus();
// Test click focus
await userEvent.click(button);
expect(button).toHaveFocus();
},
};
// Test different variants work correctly
export const VariantsInteraction: Story = {
render: function renderVariants(args) {
return (
<div className="flex gap-4">
<Button variant="primary" onClick={args.onClick}>
Primary
</Button>
<Button variant="secondary" onClick={args.onClick}>
Secondary
</Button>
<Button variant="destructive" onClick={args.onClick}>
Destructive
</Button>
<Button variant="outline" onClick={args.onClick}>
Outline
</Button>
<Button variant="ghost" onClick={args.onClick}>
Ghost
</Button>
</div>
);
},
play: async function testVariants({ args, canvasElement }) {
const canvas = within(canvasElement);
// Test all variants are rendered
const primaryBtn = canvas.getByRole("button", { name: "Primary" });
const secondaryBtn = canvas.getByRole("button", { name: "Secondary" });
const destructiveBtn = canvas.getByRole("button", { name: "Destructive" });
const outlineBtn = canvas.getByRole("button", { name: "Outline" });
const ghostBtn = canvas.getByRole("button", { name: "Ghost" });
expect(primaryBtn).toBeInTheDocument();
expect(secondaryBtn).toBeInTheDocument();
expect(destructiveBtn).toBeInTheDocument();
expect(outlineBtn).toBeInTheDocument();
expect(ghostBtn).toBeInTheDocument();
// Test clicking each variant
await userEvent.click(primaryBtn);
await userEvent.click(secondaryBtn);
await userEvent.click(destructiveBtn);
await userEvent.click(outlineBtn);
await userEvent.click(ghostBtn);
// Assert all clicks were registered
expect(args.onClick).toHaveBeenCalledTimes(5);
},
};
// Test button with icons
export const IconInteraction: Story = {
args: {
children: "With Icon",
variant: "primary",
leftIcon: <Play className="h-4 w-4" />,
rightIcon: <Plus className="h-4 w-4" />,
},
play: async function testIconButton({ args, canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "With Icon" });
// Test button with icons is rendered correctly
expect(button).toBeInTheDocument();
// Test icons are present
const icons = button.querySelectorAll("svg");
expect(icons).toHaveLength(2); // leftIcon + rightIcon
// Test button functionality with icons
await userEvent.click(button);
expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
// Test icon-only button
export const IconOnlyInteraction: Story = {
args: {
children: <Plus className="h-4 w-4" />,
variant: "icon",
size: "icon",
"aria-label": "Add item",
},
play: async function testIconOnlyButton({ args, canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Add item" });
// Test icon-only button accessibility
expect(button).toHaveAccessibleName("Add item");
// Test icon is present
const icon = button.querySelector("svg");
expect(icon).toBeInTheDocument();
// Test functionality
await userEvent.click(button);
expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
// Test multiple clicks and double click
export const MultipleClicksInteraction: Story = {
args: {
children: "Multi Click",
variant: "secondary",
},
play: async function testMultipleClicks({ args, canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Multi Click" });
// Test multiple single clicks
await userEvent.click(button);
await userEvent.click(button);
await userEvent.click(button);
expect(args.onClick).toHaveBeenCalledTimes(3);
// Test double click
await userEvent.dblClick(button);
expect(args.onClick).toHaveBeenCalledTimes(5); // 3 + 2 from double click
},
};
// Test hover states
export const HoverInteraction: Story = {
args: {
children: "Hover Me",
variant: "outline",
},
play: async function testHoverStates({ canvasElement }) {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: "Hover Me" });
// Test hover interaction
await userEvent.hover(button);
// Test unhover
await userEvent.unhover(button);
// Verify button is still functional after hover interactions
expect(button).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,158 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Input } from "./Input";
describe("Input Component", () => {
it("renders input with label", () => {
render(<Input label="Username" />);
expect(screen.getByLabelText("Username")).toBeInTheDocument();
expect(screen.getByText("Username")).toBeInTheDocument();
});
it("renders input with hidden label", () => {
render(<Input label="Username" hideLabel />);
const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("aria-label", "Username");
expect(screen.queryByText("Username")).not.toBeInTheDocument();
});
it("calls onChange when typing", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Input label="Username" onChange={handleChange} />);
const input = screen.getByLabelText("Username");
await user.type(input, "test");
expect(handleChange).toHaveBeenCalled();
expect(input).toHaveValue("test");
});
it("displays placeholder text", () => {
render(<Input label="Username" placeholder="Enter your username" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveAttribute("placeholder", "Enter your username");
});
it("uses label as placeholder when no placeholder provided", () => {
render(<Input label="Username" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveAttribute("placeholder", "Username");
});
it("displays error message", () => {
render(<Input label="Username" error="Username is required" />);
expect(screen.getByText("Username is required")).toBeInTheDocument();
const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument();
});
it("is disabled when disabled prop is true", () => {
render(<Input label="Username" disabled />);
const input = screen.getByLabelText("Username");
expect(input).toBeDisabled();
});
it("does not call onChange when disabled", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Input label="Username" disabled onChange={handleChange} />);
const input = screen.getByLabelText("Username");
await user.type(input, "test");
expect(handleChange).not.toHaveBeenCalled();
expect(input).toHaveValue("");
});
it("supports different input types", () => {
const { rerender } = render(<Input label="Email" type="email" />);
expect(screen.getByLabelText("Email")).toHaveAttribute("type", "email");
rerender(<Input label="Password" type="password" />);
expect(screen.getByLabelText("Password")).toHaveAttribute(
"type",
"password",
);
rerender(<Input label="Number" type="number" />);
expect(screen.getByLabelText("Number")).toHaveAttribute("type", "number");
});
it("handles focus and blur events", async () => {
const handleFocus = vi.fn();
const handleBlur = vi.fn();
const user = userEvent.setup();
render(
<Input label="Username" onFocus={handleFocus} onBlur={handleBlur} />,
);
const input = screen.getByLabelText("Username");
await user.click(input);
expect(handleFocus).toHaveBeenCalledTimes(1);
await user.tab();
expect(handleBlur).toHaveBeenCalledTimes(1);
});
it("accepts a default value", () => {
render(<Input label="Username" defaultValue="john_doe" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveValue("john_doe");
});
it("can be controlled with value prop", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
const { rerender } = render(
<Input label="Username" value="initial" onChange={handleChange} />,
);
const input = screen.getByLabelText("Username");
expect(input).toHaveValue("initial");
// Try typing - should call onChange but value stays controlled
await user.type(input, "x");
expect(handleChange).toHaveBeenCalled();
// Update with new controlled value
rerender(
<Input label="Username" value="updated" onChange={handleChange} />,
);
expect(input).toHaveValue("updated");
});
it("supports keyboard navigation", async () => {
const user = userEvent.setup();
render(<Input label="Username" />);
const input = screen.getByLabelText("Username");
// Tab to focus
await user.tab();
expect(input).toHaveFocus();
// Type some text
await user.keyboard("test");
expect(input).toHaveValue("test");
// Navigate within text
await user.keyboard("{Home}");
await user.keyboard("start");
expect(input).toHaveValue("starttest");
});
});

View File

@@ -1,165 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { expect, fn, userEvent, within } from "storybook/test";
import { Input } from "./Input";
const meta: Meta<typeof Input> = {
title: "Atoms/Tests/Input",
component: Input,
parameters: {
layout: "centered",
},
tags: ["!autodocs"],
args: {
onChange: fn(),
onFocus: fn(),
onBlur: fn(),
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// Test basic input functionality
export const BasicInputInteraction: Story = {
args: {
label: "Username",
placeholder: "Enter your username",
},
play: async function testBasicInput({ args, canvasElement }) {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Username");
// Test initial state
expect(input).toBeInTheDocument();
expect(input).not.toBeDisabled();
expect(input).toHaveValue("");
// Test typing
await userEvent.type(input, "test");
expect(input).toHaveValue("test");
// Test that onChange was called
expect(args.onChange).toHaveBeenCalled();
},
};
// Test input with hidden label
export const HiddenLabelInteraction: Story = {
args: {
label: "Search",
placeholder: "Search...",
hideLabel: true,
},
play: async function testHiddenLabel({ canvasElement }) {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Search");
// Test accessibility with hidden label
expect(input).toHaveAccessibleName("Search");
expect(input).toHaveAttribute("aria-label", "Search");
// Test functionality
await userEvent.type(input, "query");
expect(input).toHaveValue("query");
},
};
// Test error state
export const ErrorStateInteraction: Story = {
args: {
label: "Password",
placeholder: "Enter password",
error: "Password is required",
},
play: async function testErrorState({ canvasElement }) {
const canvas = within(canvasElement);
const input = canvas.getByPlaceholderText("Enter password");
const errorMessage = canvas.getByText("Password is required");
// Test error state rendering
expect(input).toBeInTheDocument();
expect(errorMessage).toBeInTheDocument();
// Test error styling
expect(input).toHaveClass("border-red-500");
expect(errorMessage).toHaveClass("!text-red-500");
},
};
// Test different input types
export const InputTypesInteraction: Story = {
render: function renderInputTypes() {
return (
<div className="space-y-4">
<Input label="Text Input" type="text" />
<Input label="Email Input" type="email" />
<Input label="Password Input" type="password" />
<Input label="Number Input" type="number" />
</div>
);
},
play: async function testInputTypes({ canvasElement }) {
const canvas = within(canvasElement);
// Test all input types
const textInput = canvas.getByLabelText("Text Input");
const emailInput = canvas.getByLabelText("Email Input");
const passwordInput = canvas.getByLabelText("Password Input");
const numberInput = canvas.getByLabelText("Number Input");
// Test correct types are applied
expect(textInput).toHaveAttribute("type", "text");
expect(emailInput).toHaveAttribute("type", "email");
expect(passwordInput).toHaveAttribute("type", "password");
expect(numberInput).toHaveAttribute("type", "number");
// Test that all inputs are rendered
expect(textInput).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
expect(numberInput).toBeInTheDocument();
},
};
// Test disabled state
export const DisabledInteraction: Story = {
args: {
label: "Disabled Input",
placeholder: "This is disabled",
disabled: true,
},
play: async function testDisabledInput({ args, canvasElement }) {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Disabled Input");
// Test disabled state
expect(input).toBeDisabled();
expect(input).toHaveAttribute("disabled");
// Test that disabled input has empty value and onChange is not called
// We don't test typing because disabled inputs prevent user interaction
expect(input).toHaveValue("");
expect(args.onChange).not.toHaveBeenCalled();
},
};
// Test clear input functionality
export const ClearInputInteraction: Story = {
args: {
label: "Clear Test",
placeholder: "Type and clear",
},
play: async function testClearInput({ canvasElement }) {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Clear Test");
// Type something
await userEvent.type(input, "content");
expect(input).toHaveValue("content");
// Clear with select all + delete
await userEvent.keyboard("{Control>}a{/Control}");
await userEvent.keyboard("{Delete}");
expect(input).toHaveValue("");
},
};

View File

@@ -0,0 +1,144 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { forwardRef } from "react";
import { describe, expect, it, vi } from "vitest";
import { Link } from "./Link";
// Mock Next.js Link with proper ref forwarding
vi.mock("next/link", () => ({
default: forwardRef(function MockNextLink(
{ href, children, ...props }: any,
ref: any,
) {
return (
<a ref={ref} href={href} {...props}>
{children}
</a>
);
}),
}));
describe("Link Component", () => {
it("renders internal link with correct href", () => {
render(<Link href="/dashboard">Dashboard</Link>);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/dashboard");
});
it("renders external link with target blank", () => {
render(
<Link href="https://example.com" isExternal>
External Link
</Link>,
);
const link = screen.getByRole("link", { name: "External Link" });
expect(link).toHaveAttribute("href", "https://example.com");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("handles click events", async () => {
const handleClick = vi.fn((e) => e.preventDefault());
const user = userEvent.setup();
render(
<Link href="/dashboard" onClick={handleClick}>
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
await user.click(link);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("supports keyboard navigation", async () => {
const handleKeyDown = vi.fn((e) => {
if (e.key === "Enter") e.preventDefault();
});
const user = userEvent.setup();
render(
<Link href="/dashboard" onKeyDown={handleKeyDown}>
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
await user.tab();
expect(link).toHaveFocus();
await user.keyboard("{Enter}");
expect(handleKeyDown).toHaveBeenCalled();
});
it("applies custom className", () => {
render(
<Link href="/dashboard" className="custom-class">
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toHaveClass("custom-class");
});
it("forwards ref correctly", () => {
const ref = { current: null };
render(
<Link href="/dashboard" ref={ref}>
Dashboard
</Link>,
);
// Check that ref is populated
expect(ref.current).toBeTruthy();
});
it("passes through additional props", () => {
render(
<Link href="/dashboard" data-testid="custom-link">
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toHaveAttribute("data-testid", "custom-link");
});
it("renders children correctly", () => {
render(
<Link href="/dashboard">
<span>Dashboard</span>
<span>Icon</span>
</Link>,
);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Icon")).toBeInTheDocument();
});
it("distinguishes between internal and external links", () => {
const { rerender } = render(<Link href="/internal">Internal</Link>);
let link = screen.getByRole("link", { name: "Internal" });
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
rerender(
<Link href="https://external.com" isExternal>
External
</Link>,
);
link = screen.getByRole("link", { name: "External" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});

View File

@@ -2,15 +2,16 @@ import { cn } from "@/lib/utils";
import NextLink from "next/link";
import { forwardRef } from "react";
interface LinkProps {
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: React.ReactNode;
className?: string;
isExternal?: boolean;
title?: string;
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
{ href, children, className, isExternal = false, ...props },
{ href, children, className, isExternal = false, title, ...props },
ref,
) {
const linkClasses = cn(
@@ -31,6 +32,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
target="_blank"
rel="noopener noreferrer"
className={linkClasses}
title={title}
{...props}
>
{children}

View File

@@ -0,0 +1,75 @@
import { Skeleton } from "@/components/ui/skeleton";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
describe("Skeleton Component", () => {
it("renders skeleton element", () => {
render(<Skeleton data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toBeInTheDocument();
});
it("applies custom className", () => {
render(<Skeleton className="custom-skeleton" data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveClass("custom-skeleton");
});
it("passes through HTML attributes", () => {
render(
<Skeleton
data-testid="skeleton"
role="progressbar"
aria-label="Loading content"
/>,
);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveAttribute("role", "progressbar");
expect(skeleton).toHaveAttribute("aria-label", "Loading content");
});
it("renders as a div element", () => {
render(<Skeleton data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton.tagName).toBe("DIV");
});
it("can contain children", () => {
render(
<Skeleton data-testid="skeleton">
<span>Loading...</span>
</Skeleton>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("supports style prop", () => {
render(
<Skeleton
data-testid="skeleton"
style={{ width: "100px", height: "20px" }}
/>,
);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveStyle({ width: "100px", height: "20px" });
});
it("supports onClick handler", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Skeleton data-testid="skeleton" onClick={handleClick} />);
const skeleton = screen.getByTestId("skeleton");
await user.click(skeleton);
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1 @@
export * from "@/components/ui/skeleton";

View File

@@ -1,183 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { expect, userEvent, within } from "storybook/test";
import { Text, textVariants } from "./Text";
const meta: Meta<typeof Text> = {
title: "Atoms/Tests/Text",
component: Text,
parameters: {
layout: "centered",
},
tags: ["!autodocs"],
};
export default meta;
type Story = StoryObj<typeof meta>;
// Test basic text rendering
export const BasicTextInteraction: Story = {
args: {
variant: "body",
children: "Basic text content",
},
play: async function testBasicText({ canvasElement }) {
const canvas = within(canvasElement);
const text = canvas.getByText("Basic text content");
// Test basic rendering
expect(text).toBeInTheDocument();
expect(text).toBeVisible();
expect(text).toHaveTextContent("Basic text content");
},
};
// Test all text variants render correctly
export const AllVariantsInteraction: Story = {
render: function renderAllVariants() {
return (
<div className="space-y-4">
{textVariants.map((variant) => (
<Text key={variant} variant={variant}>
{variant}: Sample text for {variant} variant
</Text>
))}
</div>
);
},
play: async function testAllVariants({ canvasElement }) {
const canvas = within(canvasElement);
// Test all variants are rendered
for (const variant of textVariants) {
const text = canvas.getByText(
new RegExp(`${variant}: Sample text for ${variant} variant`),
);
expect(text).toBeInTheDocument();
}
// Test specific heading elements
const h1 = canvas.getByText(/h1: Sample text/);
const h2 = canvas.getByText(/h2: Sample text/);
const h3 = canvas.getByText(/h3: Sample text/);
const h4 = canvas.getByText(/h4: Sample text/);
expect(h1.tagName).toBe("H1");
expect(h2.tagName).toBe("H2");
expect(h3.tagName).toBe("H3");
expect(h4.tagName).toBe("H4");
},
};
// Test custom element override
export const CustomElementInteraction: Story = {
args: {
variant: "body",
as: "span",
children: "Text as span element",
},
play: async function testCustomElement({ canvasElement }) {
const canvas = within(canvasElement);
const text = canvas.getByText("Text as span element");
// Test custom element type
expect(text.tagName).toBe("SPAN");
expect(text).toHaveTextContent("Text as span element");
},
};
// Test text with custom classes
export const CustomClassesInteraction: Story = {
args: {
variant: "body",
children: "Text with custom classes",
className: "text-red-500 underline font-bold",
},
play: async function testCustomClasses({ canvasElement }) {
const canvas = within(canvasElement);
const text = canvas.getByText("Text with custom classes");
// Test custom classes are applied
expect(text).toHaveClass("text-red-500", "underline", "font-bold");
// Test variant classes are still present
expect(text).toHaveClass("font-sans", "text-sm");
},
};
// Test text accessibility
export const AccessibilityInteraction: Story = {
render: function renderAccessibilityTest() {
return (
<div>
<Text variant="h1" role="heading" aria-level={1}>
Main Heading
</Text>
<Text variant="body">Descriptive text content</Text>
<Text variant="small" aria-label="Helper text">
SR: Screen reader text
</Text>
</div>
);
},
play: async function testAccessibility({ canvasElement }) {
const canvas = within(canvasElement);
// Test heading accessibility
const heading = canvas.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("Main Heading");
// Test aria-label
const helperText = canvas.getByLabelText("Helper text");
expect(helperText).toHaveTextContent("SR: Screen reader text");
// Test regular text
const bodyText = canvas.getByText("Descriptive text content");
expect(bodyText).toBeInTheDocument();
},
};
// Test text selection
export const TextSelectionInteraction: Story = {
args: {
variant: "large",
children: "This text can be selected and copied",
},
play: async function testTextSelection({ canvasElement }) {
const canvas = within(canvasElement);
const text = canvas.getByText("This text can be selected and copied");
// Test text selection (simulated)
// Note: Actual text selection is limited in test environment
await userEvent.click(text);
// Test double-click for word selection
await userEvent.dblClick(text);
// Verify text is still accessible after interactions
expect(text).toBeInTheDocument();
expect(text).toHaveTextContent("This text can be selected and copied");
},
};
// Test text with HTML content (should be escaped)
export const SafeHTMLInteraction: Story = {
args: {
variant: "body",
children: "<script>alert('xss')</script>Safe text content",
},
play: async function testSafeHTML({ canvasElement }) {
const canvas = within(canvasElement);
const text = canvas.getByText(
"<script>alert('xss')</script>Safe text content",
);
// Test that HTML is escaped/rendered as text
expect(text).toHaveTextContent(
"<script>alert('xss')</script>Safe text content",
);
// Test that no script elements are created
const scripts = canvasElement.querySelectorAll("script");
expect(scripts).toHaveLength(0);
},
};

View File

@@ -0,0 +1,142 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Text } from "./Text";
describe("Text Component", () => {
it("renders text content", () => {
render(<Text variant="body">Hello World</Text>);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
it("renders with different variants", () => {
const { rerender } = render(<Text variant="body">Body Text</Text>);
expect(screen.getByText("Body Text")).toBeInTheDocument();
rerender(<Text variant="body-medium">Medium Text</Text>);
expect(screen.getByText("Medium Text")).toBeInTheDocument();
rerender(<Text variant="small">Small Text</Text>);
expect(screen.getByText("Small Text")).toBeInTheDocument();
});
it("renders with custom element using as prop", () => {
render(
<Text variant="body" as="h1">
Heading Text
</Text>,
);
const element = screen.getByText("Heading Text");
expect(element.tagName).toBe("H1");
});
it("applies custom className", () => {
render(
<Text variant="body" className="custom-text">
Styled Text
</Text>,
);
const element = screen.getByText("Styled Text");
expect(element).toHaveClass("custom-text");
});
it("passes through HTML attributes", () => {
render(
<Text variant="body" data-testid="text-element" title="Tooltip text">
Text with attributes
</Text>,
);
const element = screen.getByTestId("text-element");
expect(element).toHaveAttribute("title", "Tooltip text");
});
it("supports onClick handler", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Text variant="body" onClick={handleClick}>
Clickable Text
</Text>,
);
const element = screen.getByText("Clickable Text");
await user.click(element);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("renders children correctly", () => {
render(
<Text variant="body">
<span>Child 1</span>
<span>Child 2</span>
</Text>,
);
expect(screen.getByText("Child 1")).toBeInTheDocument();
expect(screen.getByText("Child 2")).toBeInTheDocument();
});
it("supports different text variants", () => {
const variants = [
"h1",
"h2",
"h3",
"h4",
"lead",
"large",
"large-medium",
"large-semibold",
"body",
"body-medium",
"small",
"small-medium",
"subtle",
];
variants.forEach((variant) => {
const { unmount } = render(
<Text variant={variant as any} data-testid={`text-${variant}`}>
{variant} text
</Text>,
);
expect(screen.getByTestId(`text-${variant}`)).toBeInTheDocument();
unmount();
});
});
it("handles empty children", () => {
render(<Text variant="body" data-testid="empty-text"></Text>);
const element = screen.getByTestId("empty-text");
expect(element).toBeInTheDocument();
expect(element).toBeEmptyDOMElement();
});
it("supports keyboard navigation when interactive", async () => {
const handleKeyDown = vi.fn();
const user = userEvent.setup();
render(
<Text variant="body" tabIndex={0} onKeyDown={handleKeyDown}>
Interactive Text
</Text>,
);
const element = screen.getByText("Interactive Text");
// Focus with tab
await user.tab();
expect(element).toHaveFocus();
// Press key
await user.keyboard("{Enter}");
expect(handleKeyDown).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock matchMedia for jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
(global as any).IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};

View File

@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vitest/config";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
esbuild: {
jsx: "automatic",
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/src/tests/**", // Exclude Playwright tests
],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,21 @@
/// <reference types="vitest" />
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
browser: {
enabled: true,
name: "chromium",
provider: "playwright",
headless: true,
},
setupFiles: ["./src/test/setup.ts"],
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});