Testing
Unit Testing
Unit Testing with Vitest
Global Watch uses Vitest for unit and integration testing. Vitest provides a fast, modern testing experience with native TypeScript support and Jest-compatible APIs.
Configuration
Vitest Setup
The Vitest configuration is located at apps/web/vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.tsx'],
exclude: ['node_modules', 'dist', '.next'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
statements: 70,
branches: 70,
functions: 70,
lines: 70,
},
},
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
});Setup File
Create a setup file for global test configuration:
// vitest.setup.ts
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock environment variables
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://localhost:54321');
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'test-anon-key');
// Global test utilities
beforeEach(() => {
vi.clearAllMocks();
});Writing Unit Tests
Basic Test Structure
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { myFunction } from './my-module';
describe('myFunction', () => {
beforeEach(() => {
// Setup before each test
});
it('should return expected result for valid input', () => {
const result = myFunction('valid-input');
expect(result).toBe('expected-output');
});
it('should handle edge cases', () => {
expect(myFunction('')).toBe('default');
expect(myFunction(null)).toBeNull();
});
it('should throw for invalid input', () => {
expect(() => myFunction('invalid')).toThrow('Invalid input');
});
});Testing Domain Entities
Domain entities should be thoroughly tested:
// core/domain/project/__tests__/project.entity.test.ts
import { describe, it, expect } from 'vitest';
import { Project } from '../project.entity';
describe('Project Entity', () => {
describe('create()', () => {
it('should create a project with valid params', () => {
const project = Project.create({
name: 'Test Project',
accountId: 'account-123',
});
expect(project.name).toBe('Test Project');
expect(project.isArchived).toBe(false);
expect(project.createdAt).toBeInstanceOf(Date);
});
it('should throw for empty name', () => {
expect(() => Project.create({
name: '',
accountId: 'account-123',
})).toThrow('Name cannot be empty');
});
});
describe('calculateArea()', () => {
it('should return 0 for project without geometry', () => {
const project = Project.create({
name: 'Test',
accountId: 'account-123',
});
expect(project.calculateArea()).toBe(0);
});
it('should calculate area from polygon geometry', () => {
const project = Project.create({
name: 'Test',
accountId: 'account-123',
geometry: mockPolygon,
});
expect(project.calculateArea()).toBeCloseTo(100.5, 1);
});
});
describe('archive()', () => {
it('should archive an active project', () => {
const project = Project.create({
name: 'Test',
accountId: 'account-123',
});
project.archive();
expect(project.isArchived).toBe(true);
});
it('should throw when already archived', () => {
const project = Project.create({
name: 'Test',
accountId: 'account-123',
});
project.archive();
expect(() => project.archive()).toThrow('Already archived');
});
});
});Testing Server Actions
Server actions require special handling:
// app/feature/_lib/__tests__/server-actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createProject, updateProject } from '../server/server-actions';
// Mock Supabase client
vi.mock('@kit/supabase/server-client', () => ({
getSupabaseServerClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn(() => ({
data: { id: 'new-id', name: 'Test' },
error: null,
})),
})),
})),
})),
})),
}));
describe('Server Actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createProject', () => {
it('should create a project successfully', async () => {
const formData = new FormData();
formData.append('name', 'New Project');
formData.append('accountId', 'account-123');
const result = await createProject(formData);
expect(result.success).toBe(true);
expect(result.data.id).toBe('new-id');
});
it('should return error for invalid data', async () => {
const formData = new FormData();
// Missing required fields
const result = await createProject(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});Testing React Components
Use React Testing Library for component tests:
// components/__tests__/project-card.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProjectCard } from '../project-card';
describe('ProjectCard', () => {
const mockProject = {
id: '1',
name: 'Test Project',
area: 100.5,
isArchived: false,
};
it('should render project name', () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText('Test Project')).toBeInTheDocument();
});
it('should display formatted area', () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText('100.5 ha')).toBeInTheDocument();
});
it('should call onArchive when archive button clicked', () => {
const onArchive = vi.fn();
render(<ProjectCard project={mockProject} onArchive={onArchive} />);
fireEvent.click(screen.getByRole('button', { name: /archive/i }));
expect(onArchive).toHaveBeenCalledWith('1');
});
it('should show archived badge when project is archived', () => {
render(<ProjectCard project={{ ...mockProject, isArchived: true }} />);
expect(screen.getByText('Archived')).toBeInTheDocument();
});
});Testing Hooks
Custom hooks can be tested with @testing-library/react:
// hooks/__tests__/use-project.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useProject } from '../use-project';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('useProject', () => {
it('should fetch project data', async () => {
const { result } = renderHook(
() => useProject('project-123'),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data.name).toBe('Test Project');
});
it('should handle loading state', () => {
const { result } = renderHook(
() => useProject('project-123'),
{ wrapper: createWrapper() }
);
expect(result.current.isLoading).toBe(true);
});
});Testing Patterns
Testing Result Types
Global Watch uses Result types for error handling:
import { describe, it, expect } from 'vitest';
import { Result } from '~/core/shared/result';
import { validateEmail } from '../validators';
describe('validateEmail', () => {
it('should return Ok for valid email', () => {
const result = validateEmail('user@example.com');
expect(Result.isOk(result)).toBe(true);
if (result.ok) {
expect(result.value.toString()).toBe('user@example.com');
}
});
it('should return Err for invalid email', () => {
const result = validateEmail('invalid-email');
expect(Result.isErr(result)).toBe(true);
if (!result.ok) {
expect(result.error.message).toContain('Invalid email');
}
});
});Testing Async Operations
import { describe, it, expect, vi } from 'vitest';
describe('async operations', () => {
it('should handle successful async operation', async () => {
const result = await fetchData('valid-id');
expect(result.ok).toBe(true);
expect(result.value).toBeDefined();
});
it('should handle async errors', async () => {
const result = await fetchData('invalid-id');
expect(result.ok).toBe(false);
expect(result.error).toBeInstanceOf(NotFoundError);
});
it('should timeout after specified duration', async () => {
vi.useFakeTimers();
const promise = fetchWithTimeout('slow-endpoint', 1000);
vi.advanceTimersByTime(1500);
await expect(promise).rejects.toThrow('Timeout');
vi.useRealTimers();
});
});Testing with Mocks
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock module
vi.mock('../api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
import { apiClient } from '../api-client';
import { fetchProjects } from '../project-service';
describe('fetchProjects', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call API with correct parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: [{ id: '1', name: 'Project 1' }],
});
await fetchProjects({ accountId: 'account-123' });
expect(apiClient.get).toHaveBeenCalledWith('/projects', {
params: { accountId: 'account-123' },
});
});
it('should transform API response', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: [{ id: '1', name: 'Project 1' }],
});
const result = await fetchProjects({ accountId: 'account-123' });
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project);
});
});Integration Tests
Integration tests verify components work together:
// __tests__/rls-policies.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClient } from '@supabase/supabase-js';
describe('RLS Policies', () => {
let supabase;
let testUser;
beforeAll(async () => {
// Setup test database connection
supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Create test user
testUser = await createTestUser(supabase);
});
afterAll(async () => {
// Cleanup
await deleteTestUser(supabase, testUser.id);
});
describe('projects table', () => {
it('should allow user to read own projects', async () => {
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('account_id', testUser.accountId);
expect(error).toBeNull();
expect(data).toBeDefined();
});
it('should prevent user from reading other projects', async () => {
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('account_id', 'other-account');
expect(data).toHaveLength(0);
});
});
});Running Tests
Common Commands
# Run all tests
pnpm --filter web test:run
# Run in watch mode
pnpm --filter web test
# Run specific file
pnpm --filter web test:run project.entity.test.ts
# Run tests matching pattern
pnpm --filter web test:run --grep "should calculate area"
# Run with coverage
pnpm --filter web test:coverage
# Run with UI
pnpm --filter web test:uiCoverage Reports
Generate and view coverage reports:
# Generate coverage
pnpm --filter web test:coverage
# Open HTML report
open apps/web/coverage/index.htmlBest Practices
1. One Assertion Per Test (When Possible)
// ✅ GOOD - Clear, focused tests
it('should set name correctly', () => {
const project = Project.create({ name: 'Test' });
expect(project.name).toBe('Test');
});
it('should set default status to active', () => {
const project = Project.create({ name: 'Test' });
expect(project.isArchived).toBe(false);
});
// ❌ AVOID - Multiple unrelated assertions
it('should create project', () => {
const project = Project.create({ name: 'Test' });
expect(project.name).toBe('Test');
expect(project.isArchived).toBe(false);
expect(project.createdAt).toBeDefined();
expect(project.id).toBeDefined();
});2. Use Test Factories
// test-utils/factories.ts
export function createTestProject(overrides = {}) {
return Project.create({
name: 'Test Project',
accountId: 'account-123',
geometry: null,
...overrides,
});
}
// In tests
it('should archive project', () => {
const project = createTestProject({ name: 'My Project' });
project.archive();
expect(project.isArchived).toBe(true);
});3. Avoid Test Interdependence
// ✅ GOOD - Independent tests
describe('Project', () => {
it('test 1', () => {
const project = createTestProject();
// Test in isolation
});
it('test 2', () => {
const project = createTestProject();
// Test in isolation
});
});
// ❌ BAD - Tests depend on each other
let sharedProject;
it('test 1', () => {
sharedProject = createTestProject();
});
it('test 2', () => {
// Depends on test 1 running first
sharedProject.archive();
});Next Steps
- Property-Based Testing - Testing with fast-check
- E2E Testing - Playwright setup and patterns
- Testing Overview - General testing strategy