Testing Patterns
This project uses Vitest + Testing Library for unit tests and Playwright for E2E tests. All tests follow strict conventions.
IMPORTANT: Never hardcode test data inline. Always use mock factories from the feature's mocks/ folder. If mock factories don't exist yet, create them first before writing tests.
Unit Tests (Vitest)
Location & Structure
- •Co-located next to source:
[name].test.ts(x)inside the feature - •
describe/itblocks with AAA pattern (Arrange, Act, Assert) - •Readable names:
it("should show error when API returns 500")
Query Priority (Testing Library)
Always prefer the most accessible query:
- •
getByRole— best (semantic, accessible) - •
getByLabelText— good (form elements) - •
getByText— ok (visible text) - •
getByTestId— last resort
NEVER use CSS selectors or XPath in tests.
Use userEvent for interactions, never fireEvent.
Mock Factories
Never hardcode test data. Always use mock factories from ../mocks/.
See examples/mock-factory.ts for the pattern.
Rules:
- •One file per entity:
[name].mock.ts - •Factory:
createMock[Entity](overrides?: Partial<Entity>) - •List factory:
createMock[Entity]List(count, overrides?) - •DTO factories:
createMock[Create|Update][Entity]Input(overrides?) - •Deterministic defaults (no
Math.random())
// src/features/tasks/mocks/task.mock.ts
import type { Task } from "../types/task";
export function createMockTask(overrides?: Partial<Task>): Task {
return {
id: "task-1",
title: "Default task",
status: "pending",
createdAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
export function createMockTaskList(count = 3, overrides?: Partial<Task>): Task[] {
return Array.from({ length: count }, (_, i) =>
createMockTask({ id: `task-${i + 1}`, title: `Task ${i + 1}`, ...overrides })
);
}
Decision Tree: What Test Type Do I Need?
What am I testing?
├── A React component?
│ └── Component test with @testing-library/react
│ ├── Does it fetch data? → Mock apiClient, test loading/success/error/empty
│ ├── Does it accept props? → Test with different prop values
│ ├── Does it have user interactions? → Test with userEvent
│ └── Does it use feedback states? → Verify EmptyState/ErrorState/LoadingState render
├── A Zod schema?
│ └── Schema test with parse/safeParse
│ ├── Valid data → schema.parse() should succeed
│ ├── Invalid data → schema.safeParse() should fail with correct errors
│ └── Edge cases → optional fields, empty strings, boundary values
├── A custom hook?
│ └── Hook test with @testing-library/react renderHook
│ ├── Does it use React Query? → Wrap in QueryClientProvider
│ └── Does it use Zustand? → Reset store in beforeEach
├── A Zustand store?
│ └── Store test
│ ├── Initial state → verify defaults
│ ├── Actions → verify state changes
│ └── Reset in beforeEach → use[Name]Store.setState(initialState)
├── A query hook?
│ └── Query hook test
│ ├── Mock @/lib/api-client
│ ├── Fresh QueryClient per test
│ └── Test success, error, and loading states
└── A utility function?
└── Simple unit test with input/output assertions
Component Test Template
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TaskList } from "./task-list";
import { createMockTaskList } from "../mocks/task.mock";
vi.mock("@/lib/api-client", () => ({
apiClient: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe("TaskList", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render tasks when data is available", async () => {
// Arrange
const tasks = createMockTaskList(3);
const { apiClient } = await import("@/lib/api-client");
vi.mocked(apiClient).mockResolvedValue({ data: tasks, error: null });
// Act
render(<TaskList />, { wrapper: createWrapper() });
// Assert
expect(await screen.findByText("Task 1")).toBeInTheDocument();
});
it("should render EmptyState when no tasks exist", async () => {
// Arrange
const { apiClient } = await import("@/lib/api-client");
vi.mocked(apiClient).mockResolvedValue({ data: [], error: null });
// Act
render(<TaskList />, { wrapper: createWrapper() });
// Assert
expect(await screen.findByText(/no tasks/i)).toBeInTheDocument();
});
it("should render ErrorState when API fails", async () => {
// Arrange
const { apiClient } = await import("@/lib/api-client");
vi.mocked(apiClient).mockRejectedValue(new Error("Server error"));
// Act
render(<TaskList />, { wrapper: createWrapper() });
// Assert
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
Schema Test Template
import { describe, it, expect } from "vitest";
import { taskSchema, createTaskInputSchema } from "../schemas/task.schema";
import { createMockTask } from "../mocks/task.mock";
describe("taskSchema", () => {
it("should parse valid task data", () => {
const task = createMockTask();
expect(() => taskSchema.parse(task)).not.toThrow();
});
it("should reject task without required title", () => {
const result = taskSchema.safeParse({ id: "1", status: "pending" });
expect(result.success).toBe(false);
});
it("should reject invalid status enum value", () => {
const task = createMockTask({ status: "invalid" as never });
const result = taskSchema.safeParse(task);
expect(result.success).toBe(false);
});
});
Scenarios to Always Cover
For Components:
- •Default render with required props
- •All interactive states (hover, focus, disabled)
- •Empty data →
EmptyStaterenders (NOTnull) - •Error state →
ErrorStaterenders - •Loading state →
LoadingStaterenders
For Schemas:
- •Valid data parses correctly
- •Invalid data is rejected with correct errors
- •Optional fields handle
undefined - •Edge cases: empty strings, boundary values, enum values
For Hooks/Queries:
- •Fresh
QueryClientper test - •Mock
@/lib/api-client - •Test loading, success, and error states
- •Verify mutations invalidate correct queries
For Stores:
- •Initial state is correct
- •Actions update state correctly
- •Reset state in
beforeEach
Filter Utilities
- •Test
applyClientFilters()as a pure function (no React needed) - •Test search: matching, partial, case-insensitive, empty query, no match
- •Test sorting: string/numeric asc/desc, null handling, immutability
E2E Tests (Playwright)
Location & Structure
- •Tests:
tests/e2e/[feature].spec.ts - •Page objects:
tests/e2e/fixtures/[page].fixture.ts
Page Object Model
Create for pages with 3+ interactions. See examples/page-object.ts.
// tests/e2e/fixtures/tasks-page.fixture.ts
import type { Page, Locator } from "@playwright/test";
export class TasksPage {
readonly page: Page;
readonly heading: Locator;
readonly createButton: Locator;
readonly taskList: Locator;
readonly emptyState: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole("heading", { name: /tasks/i });
this.createButton = page.getByRole("button", { name: /create task/i });
this.taskList = page.getByRole("list", { name: /tasks/i });
this.emptyState = page.getByText(/no tasks/i);
}
async goto() {
await this.page.goto("/tasks");
}
async createTask(title: string) {
await this.createButton.click();
await this.page.getByLabel(/title/i).fill(title);
await this.page.getByRole("button", { name: /save/i }).click();
}
}
Selector Priority
- •
getByRole>getByLabel>getByText>getByPlaceholder>getByTestId - •NEVER use CSS selectors or XPath
Key Rules
- •Each test is self-contained and independent
- •SSR-first: first load shows content immediately (no loading spinners to wait for)
- •Use
page.route()for API mocking in edge cases - •Web-first assertions:
await expect(locator).toBeVisible() - •Test responsive: 375px (mobile) and 1280px (desktop)
Scenarios to Cover
- •Happy path (main user flow end-to-end)
- •Form validation (required fields, invalid input, error messages)
- •Navigation (links work, breadcrumbs update, URL matches)
- •Error states (
ErrorStaterenders when API fails) - •Empty states (
EmptyStaterenders, not blank page) - •Responsive layouts (mobile 375px and desktop 1280px)
- •Sidebar collapsed/expanded (dashboard pages)
DO NOT
- •DO NOT hardcode test data inline — always use mock factories from
../mocks/. - •DO NOT use
fireEvent— always useuserEvent. - •DO NOT import from the barrel in test files — use relative imports within the feature.
- •DO NOT mock the unit under test — mock its dependencies.
- •DO NOT use CSS selectors or XPath — use Testing Library / Playwright locators.
- •DO NOT skip testing feedback states (empty, error, loading) — every data component must handle all three.
- •DO NOT share state between tests — each test must be independent.
- •DO NOT use
Math.random()in mock factories — use deterministic values. - •DO NOT skip running tests after writing them — run
npx vitest run [path]and fix all failures.