Global WatchGlobal Watch Docs
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:ui

Coverage Reports

Generate and view coverage reports:

# Generate coverage
pnpm --filter web test:coverage

# Open HTML report
open apps/web/coverage/index.html

Best 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

On this page