ADR-0014: Vitest for Testing Framework
Status
Accepted - 2025-01-26
Context
TVL Platform requires a testing framework for unit tests, integration tests, and RLS policy tests with the following requirements:
Business Requirements
- High code quality (80% coverage minimum)
- Fast test feedback (developer productivity)
- Confidence in refactoring (comprehensive test suite)
Technical Requirements
- TypeScript support (no compilation needed)
- Fast execution (< 10 seconds for unit tests)
- ESM support (modern JavaScript modules)
- Monorepo support (test multiple packages)
- Coverage reporting (80% minimum)
- Watch mode (re-run tests on file changes)
Constraints
- TypeScript 5.3+ (ADR-0005)
- Vite for frontend build (Vitest integrates well)
- pnpm monorepo (ADR-0007, ADR-0010)
- Must work with PostgreSQL (integration tests)
Decision
Vitest as the testing framework for all packages (frontend, backend, database).
Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    globals: true,
    environment: 'node', // or 'jsdom' for frontend
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
});
Rationale
- Fast: 10x faster than Jest (native ESM, no transpilation)
- TypeScript: Works with TypeScript out-of-the-box
- Vite Integration: Same config as Vite (shared across build and test)
- Jest-Compatible API: Easy migration from Jest
- ESM-Native: No configuration needed for ESM modules
Alternatives Considered
Alternative 1: Jest
Rejected
Pros:
- Most popular testing framework
- Excellent documentation
- Large ecosystem (many plugins)
- Mature (since 2014)
Cons:
- Slow: Transpiles all files (TypeScript → JavaScript)
- ESM Issues: Poor ESM support (requires experimental flags)
- Configuration: Complex setup for TypeScript + ESM
- Transform Overhead: Babel/SWC required for modern syntax
Decision: Vitest faster and better ESM/TypeScript support.
Alternative 2: Mocha + Chai
Rejected
Pros:
- Flexible (bring your own assertion library)
- Lightweight
- Mature
Cons:
- Multiple Libraries: Mocha (runner) + Chai (assertions) + Sinon (mocks)
- Configuration: Must configure each separately
- No Built-in Coverage: Must add Istanbul/nyc
- Slower: Not optimized like Vitest
Decision: Vitest all-in-one solution simpler.
Alternative 3: AVA
Rejected
Pros:
- Fast (parallel by default)
- Minimal API
- ESM support
Cons:
- Less Popular: Smaller ecosystem
- Different API: Not Jest-compatible
- Learning Curve: Team familiar with Jest API
- No Built-in Coverage: Must add separate tool
Decision: Vitest faster and Jest-compatible API.
Alternative 4: Node.js Native Test Runner
Rejected (for now)
Pros:
- Built into Node.js 20 (no dependency)
- Fast
- Official support
Cons:
- Immature: Recently added (Node.js 18)
- No Coverage: Must add external tool
- No Mocking: Limited mock support
- No Watch Mode: Must use --watchflag
- No Snapshot Testing: Not supported
Decision: Vitest more feature-complete. Revisit when Node.js test runner matures.
Consequences
Positive
- 
Performance - 10x faster than Jest (native ESM, no transpilation)
- Parallel execution (uses all CPU cores)
- Fast watch mode (only re-run affected tests)
- Example: 1000 tests in 5 seconds (vs. 50 seconds with Jest)
 
- 
Developer Experience - Jest-compatible API (easy to learn)
- TypeScript works out-of-the-box
- Great error messages (diff output)
- Watch mode with smart re-runs
 
- 
Vite Integration - Shares Vite config (no duplication)
- Same plugins and transformers
- Consistent behavior (build and test)
 
- 
Built-in Features - Coverage reporting (v8 or Istanbul)
- Snapshot testing
- Mock functions (vi.fn())
- DOM testing (@testing-library/react)
- Benchmarking
 
- 
Monorepo Support - Works with pnpm workspaces
- Test all packages: pnpm -r test
- Workspace-aware test filtering
 
Negative
- 
Smaller Ecosystem - Fewer plugins than Jest
- Mitigation: Core functionality sufficient, ecosystem growing
 
- 
Newer Project - Less mature than Jest (started 2021)
- Mitigation: Stable and actively maintained, 1.0 released
 
- 
Learning Curve - Developers must learn Vitest API (if unfamiliar with Jest)
- Mitigation: API nearly identical to Jest
 
- 
Some Jest Plugins Incompatible - Must find Vitest alternatives
- Mitigation: Most common plugins have Vitest equivalents
 
Test Types
1. Unit Tests
// src/services/BookingService.test.ts
import { describe, it, expect } from 'vitest';
import { BookingService } from './BookingService';
describe('BookingService', () => {
  it('should calculate total with fees', () => {
    const service = new BookingService();
    const total = service.calculateTotal(100, { cleaningFee: 25, tax: 0.1 });
    expect(total).toBe(135); // 100 + 25 + 10
  });
  it('should throw error for negative price', () => {
    const service = new BookingService();
    expect(() => {
      service.calculateTotal(-100, { cleaningFee: 25, tax: 0.1 });
    }).toThrowError('Price must be positive');
  });
});
2. Integration Tests (Database)
// packages/database/tests/bookings.integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { db } from '../src/client';
import { bookings } from '../src/schema';
describe('Bookings Repository', () => {
  beforeEach(async () => {
    await db.execute(sql`TRUNCATE TABLE bookings CASCADE`);
  });
  it('should create booking with correct org_id', async () => {
    const [booking] = await db.insert(bookings).values({
      orgId: testOrgId,
      accountId: testAccountId,
      guestName: 'John Doe',
      checkIn: new Date('2025-02-01'),
      checkOut: new Date('2025-02-05'),
      totalCents: 50000,
      status: 'pending'
    }).returning();
    expect(booking.orgId).toBe(testOrgId);
    expect(booking.guestName).toBe('John Doe');
  });
});
3. RLS Policy Tests (Critical)
// packages/database/tests/rls/bookings.rls.test.ts
import { describe, it, expect } from 'vitest';
import { db } from '../../src/client';
import { bookings } from '../../src/schema';
describe('RLS: bookings table', () => {
  it('should prevent cross-org access', async () => {
    const orgA = await createTestOrg('Org A');
    const orgB = await createTestOrg('Org B');
    // Create booking in org B
    const [bookingB] = await db.insert(bookings).values({
      orgId: orgB.id,
      accountId: orgB.accountId,
      guestName: 'John Doe',
      checkIn: new Date('2025-02-01'),
      checkOut: new Date('2025-02-05'),
      totalCents: 50000,
      status: 'pending'
    }).returning();
    // Set session to org A
    await db.execute(sql`SET app.current_org_id = ${orgA.id}`);
    // Try to access org B booking
    const result = await db.select()
      .from(bookings)
      .where(eq(bookings.id, bookingB.id));
    expect(result).toHaveLength(0); // RLS blocks access
  });
});
4. Frontend Component Tests
// apps/web/components/BookingForm.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BookingForm } from './BookingForm';
describe('BookingForm', () => {
  it('should show error for empty guest name', async () => {
    render(<BookingForm />);
    const submitButton = screen.getByRole('button', { name: /submit/i });
    fireEvent.click(submitButton);
    expect(await screen.findByText(/guest name is required/i)).toBeInTheDocument();
  });
});
Common Commands
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests with coverage
pnpm test:coverage
# Run specific test file
pnpm test src/services/BookingService.test.ts
# Run tests matching pattern
pnpm test --grep "BookingService"
# Run only RLS tests
pnpm test:rls
# Run tests in UI mode (interactive)
pnpm vitest --ui
Coverage Configuration
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8', // Fast native V8 coverage
      reporter: ['text', 'json', 'html'],
      reportsDirectory: './coverage',
      exclude: [
        'node_modules/',
        'dist/',
        '**/*.test.ts',
        '**/*.spec.ts',
        '**/types.ts',
        '**/index.ts' // Re-exports
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
});
Mocking
import { vi } from 'vitest';
// Mock function
const mockFn = vi.fn();
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
// Mock module
vi.mock('./database', () => ({
  db: {
    select: vi.fn().mockResolvedValue([{ id: '123' }])
  }
}));
// Spy on function
const spy = vi.spyOn(service, 'calculateTotal');
service.calculateTotal(100, { cleaningFee: 25, tax: 0.1 });
expect(spy).toHaveBeenCalledOnce();
Snapshot Testing
import { describe, it, expect } from 'vitest';
describe('BookingCard', () => {
  it('should match snapshot', () => {
    const booking = {
      id: '123',
      guestName: 'John Doe',
      checkIn: '2025-02-01',
      checkOut: '2025-02-05'
    };
    const card = renderBookingCard(booking);
    expect(card).toMatchSnapshot();
  });
});
Benchmarking
import { bench, describe } from 'vitest';
describe('calculateTotal performance', () => {
  bench('with fees', () => {
    calculateTotal(100, { cleaningFee: 25, tax: 0.1 });
  });
  bench('without fees', () => {
    calculateTotal(100, { cleaningFee: 0, tax: 0 });
  });
});
Validation Checklist
- Vitest configured in all packages
- Coverage thresholds enforced (80%)
- RLS tests for all multi-tenant tables
- Integration tests for database operations
- Unit tests for business logic
- Frontend component tests
- Watch mode works
- CI/CD runs tests