Testing Guide
Sayr uses Vitest for testing. This guide covers how to write and run tests effectively.
Running Tests
Section titled “Running Tests”All Tests
Section titled “All Tests”pnpm -F start testWatch Mode
Section titled “Watch Mode”Run tests in watch mode during development:
pnpm -F start test -- --watchSpecific Tests
Section titled “Specific Tests”# Run tests matching a patternpnpm -F start test -- --testNamePattern="createTask"
# Run a specific test filepnpm -F start test -- src/lib/__tests__/task.test.ts
# Run tests in a directorypnpm -F start test -- src/lib/__tests__/With Coverage
Section titled “With Coverage”pnpm -F start test -- --coverageTest File Structure
Section titled “Test File Structure”Test files should be placed in __tests__ directories adjacent to the code they test:
src/├── lib/│ ├── __tests__/│ │ ├── task.test.ts│ │ └── organization.test.ts│ ├── task.ts│ └── organization.ts├── components/│ ├── __tests__/│ │ └── TaskCard.test.tsx│ └── TaskCard.tsxFile Naming
Section titled “File Naming”- Unit tests:
{name}.test.tsor{name}.test.tsx - Integration tests:
{name}.integration.test.ts
Writing Tests
Section titled “Writing Tests”Basic Test Structure
Section titled “Basic Test Structure”import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("TaskService", () => { beforeEach(() => { // Setup before each test });
afterEach(() => { // Cleanup after each test });
describe("createTask", () => { it("should create a task with valid data", async () => { const task = await createTask({ title: "Test Task", organizationId: "org-123", });
expect(task).toBeDefined(); expect(task.title).toBe("Test Task"); });
it("should throw error with invalid data", async () => { await expect( createTask({ title: "", organizationId: "" }) ).rejects.toThrow("Invalid task data"); }); });});Testing Async Functions
Section titled “Testing Async Functions”import { describe, it, expect } from "vitest";
describe("fetchTasks", () => { it("should return tasks for organization", async () => { const tasks = await fetchTasks("org-123");
expect(tasks).toBeInstanceOf(Array); expect(tasks.length).toBeGreaterThan(0); });
it("should handle errors gracefully", async () => { // Test with invalid org ID const result = await fetchTasks("invalid-org");
expect(result).toEqual([]); });});Testing with Mocks
Section titled “Testing with Mocks”import { describe, it, expect, vi, beforeEach } from "vitest";import { createTask } from "../task";
// Mock the database modulevi.mock("@repo/database", () => ({ db: { insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([{ id: "task-123", title: "Test" }]), }, schema: { task: {}, },}));
describe("createTask", () => { beforeEach(() => { vi.clearAllMocks(); });
it("should insert task into database", async () => { const result = await createTask({ title: "New Task", organizationId: "org-123", });
expect(result).toEqual({ id: "task-123", title: "Test" }); });});Testing React Components
Section titled “Testing React Components”import { describe, it, expect } from "vitest";import { render, screen, fireEvent } from "@testing-library/react";import { TaskCard } from "../TaskCard";
describe("TaskCard", () => { const mockTask = { id: "task-123", title: "Test Task", status: "todo", description: "A test task", };
it("should render task title", () => { render(<TaskCard task={mockTask} />);
expect(screen.getByText("Test Task")).toBeInTheDocument(); });
it("should call onClick when clicked", () => { const handleClick = vi.fn(); render(<TaskCard task={mockTask} onClick={handleClick} />);
fireEvent.click(screen.getByRole("article"));
expect(handleClick).toHaveBeenCalledWith(mockTask); });
it("should display status badge", () => { render(<TaskCard task={mockTask} />);
expect(screen.getByText("todo")).toBeInTheDocument(); });});Testing Hooks
Section titled “Testing Hooks”import { describe, it, expect } from "vitest";import { renderHook, act } from "@testing-library/react";import { useTaskFilter } from "../useTaskFilter";
describe("useTaskFilter", () => { it("should initialize with default filters", () => { const { result } = renderHook(() => useTaskFilter());
expect(result.current.filters).toEqual({ status: null, priority: null, assignee: null, }); });
it("should update filters", () => { const { result } = renderHook(() => useTaskFilter());
act(() => { result.current.setFilter("status", "in-progress"); });
expect(result.current.filters.status).toBe("in-progress"); });
it("should clear all filters", () => { const { result } = renderHook(() => useTaskFilter());
act(() => { result.current.setFilter("status", "done"); result.current.setFilter("priority", "high"); result.current.clearFilters(); });
expect(result.current.filters).toEqual({ status: null, priority: null, assignee: null, }); });});Mocking Patterns
Section titled “Mocking Patterns”Mocking API Calls
Section titled “Mocking API Calls”import { vi } from "vitest";
// Mock fetch globallyglobal.fetch = vi.fn();
beforeEach(() => { vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ success: true, data: [] }), } as Response);});
afterEach(() => { vi.restoreAllMocks();});Mocking Modules
Section titled “Mocking Modules”// Mock entire modulevi.mock("@repo/database", () => ({ db: mockDb, schema: mockSchema,}));
// Mock specific exportsvi.mock("@repo/util", async () => { const actual = await vi.importActual("@repo/util"); return { ...actual, generateSlug: vi.fn().mockReturnValue("mocked-slug"), };});Mocking Environment Variables
Section titled “Mocking Environment Variables”import { vi, beforeEach, afterEach } from "vitest";
describe("config", () => { const originalEnv = process.env;
beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; });
afterEach(() => { process.env = originalEnv; });
it("should use production URL in production", async () => { process.env.NODE_ENV = "production"; process.env.API_URL = "https://api.sayr.io";
const { config } = await import("../config");
expect(config.apiUrl).toBe("https://api.sayr.io"); });});Test Utilities
Section titled “Test Utilities”Custom Render Function
Section titled “Custom Render Function”Create a custom render function that includes providers:
import { render, RenderOptions } from "@testing-library/react";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { ReactElement } from "react";
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, });
function AllProviders({ children }: { children: React.ReactNode }) { const queryClient = createTestQueryClient();
return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );}
const customRender = ( ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) => render(ui, { wrapper: AllProviders, ...options });
export * from "@testing-library/react";export { customRender as render };Test Fixtures
Section titled “Test Fixtures”Create reusable test data:
import type { TaskWithLabels } from "@repo/database";
export const createMockTask = ( overrides?: Partial<TaskWithLabels>): TaskWithLabels => ({ id: "task-123", organizationId: "org-123", title: "Test Task", description: "A test task description", status: "todo", priority: "medium", createdAt: new Date("2024-01-01"), updatedAt: new Date("2024-01-01"), createdById: "user-123", labels: [], assignees: [], comments: [], createdBy: { id: "user-123", name: "Test User", image: null, }, ...overrides,});
export const mockTasks: TaskWithLabels[] = [ createMockTask({ id: "task-1", title: "First Task" }), createMockTask({ id: "task-2", title: "Second Task", status: "in-progress" }), createMockTask({ id: "task-3", title: "Third Task", status: "done" }),];Best Practices
Section titled “Best Practices”1. Test Behavior, Not Implementation
Section titled “1. Test Behavior, Not Implementation”// Bad - testing implementation detailsit("should call setState with new value", () => { const setState = vi.spyOn(React, "useState"); // ...});
// Good - testing behaviorit("should display updated value after change", () => { render(<Counter />); fireEvent.click(screen.getByText("+")); expect(screen.getByText("1")).toBeInTheDocument();});2. Use Descriptive Test Names
Section titled “2. Use Descriptive Test Names”// Badit("works", () => { /* ... */ });
// Goodit("should return empty array when organization has no tasks", () => { /* ... */ });3. Arrange-Act-Assert Pattern
Section titled “3. Arrange-Act-Assert Pattern”it("should update task status", async () => { // Arrange const task = createMockTask({ status: "todo" });
// Act const updated = await updateTaskStatus(task.id, "done");
// Assert expect(updated.status).toBe("done");});4. Test Edge Cases
Section titled “4. Test Edge Cases”describe("parseTaskId", () => { it("should parse valid task ID", () => { expect(parseTaskId("TASK-123")).toBe(123); });
it("should return null for invalid format", () => { expect(parseTaskId("invalid")).toBeNull(); });
it("should handle empty string", () => { expect(parseTaskId("")).toBeNull(); });
it("should handle null input", () => { expect(parseTaskId(null as any)).toBeNull(); });});5. Keep Tests Independent
Section titled “5. Keep Tests Independent”Each test should be able to run in isolation:
// Bad - tests depend on each otherlet task: Task;
it("should create task", async () => { task = await createTask({ title: "Test" });});
it("should update task", async () => { // Depends on previous test! await updateTask(task.id, { title: "Updated" });});
// Good - tests are independentit("should create task", async () => { const task = await createTask({ title: "Test" }); expect(task).toBeDefined();});
it("should update task", async () => { const task = await createTask({ title: "Test" }); const updated = await updateTask(task.id, { title: "Updated" }); expect(updated.title).toBe("Updated");});Debugging Tests
Section titled “Debugging Tests”Run Single Test
Section titled “Run Single Test”pnpm -F start test -- --testNamePattern="should create task"Verbose Output
Section titled “Verbose Output”pnpm -F start test -- --reporter=verboseDebug Mode
Section titled “Debug Mode”Add debugger statement and run:
pnpm -F start test -- --inspect-brkThen open Chrome DevTools at chrome://inspect.