Guidelines
Testing Guide
Writing and running tests in the Sayr codebase
Sayr uses Vitest for testing. This guide covers how to write and run tests effectively.
Running Tests
All Tests
pnpm -F start testWatch Mode
Run tests in watch mode during development:
pnpm -F start test -- --watchSpecific Tests
# Run tests matching a pattern
pnpm -F start test -- --testNamePattern="createTask"
# Run a specific test file
pnpm -F start test -- src/lib/__tests__/task.test.ts
# Run tests in a directory
pnpm -F start test -- src/lib/__tests__/With Coverage
pnpm -F start test -- --coverageTest 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
- Unit tests:
{name}.test.tsor{name}.test.tsx - Integration tests:
{name}.integration.test.ts
Writing Tests
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
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
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTask } from "../task";
// Mock the database module
vi.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
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
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
Mocking API Calls
import { vi } from "vitest";
// Mock fetch globally
global.fetch = vi.fn();
beforeEach(() => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: [] }),
} as Response);
});
afterEach(() => {
vi.restoreAllMocks();
});Mocking Modules
// Mock entire module
vi.mock("@repo/database", () => ({
db: mockDb,
schema: mockSchema,
}));
// Mock specific exports
vi.mock("@repo/util", async () => {
const actual = await vi.importActual("@repo/util");
return {
...actual,
generateSlug: vi.fn().mockReturnValue("mocked-slug"),
};
});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
Custom Render Function
Create a custom render function that includes providers:
// src/test/utils.tsx
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
Create reusable test data:
// src/test/fixtures/task.ts
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
1. Test Behavior, Not Implementation
// Bad - testing implementation details
it("should call setState with new value", () => {
const setState = vi.spyOn(React, "useState");
// ...
});
// Good - testing behavior
it("should display updated value after change", () => {
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(screen.getByText("1")).toBeInTheDocument();
});2. Use Descriptive Test Names
// Bad
it("works", () => { /* ... */ });
// Good
it("should return empty array when organization has no tasks", () => { /* ... */ });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
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
Each test should be able to run in isolation:
// Bad - tests depend on each other
let 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 independent
it("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
Run Single Test
pnpm -F start test -- --testNamePattern="should create task"Verbose Output
pnpm -F start test -- --reporter=verboseDebug Mode
Add debugger statement and run:
pnpm -F start test -- --inspect-brkThen open Chrome DevTools at chrome://inspect.