Skip to main content

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

  1. Fast: 10x faster than Jest (native ESM, no transpilation)
  2. TypeScript: Works with TypeScript out-of-the-box
  3. Vite Integration: Same config as Vite (shared across build and test)
  4. Jest-Compatible API: Easy migration from Jest
  5. 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 --watch flag
  • No Snapshot Testing: Not supported

Decision: Vitest more feature-complete. Revisit when Node.js test runner matures.


Consequences

Positive

  1. 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)
  2. 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
  3. Vite Integration

    • Shares Vite config (no duplication)
    • Same plugins and transformers
    • Consistent behavior (build and test)
  4. Built-in Features

    • Coverage reporting (v8 or Istanbul)
    • Snapshot testing
    • Mock functions (vi.fn())
    • DOM testing (@testing-library/react)
    • Benchmarking
  5. Monorepo Support

    • Works with pnpm workspaces
    • Test all packages: pnpm -r test
    • Workspace-aware test filtering

Negative

  1. Smaller Ecosystem

    • Fewer plugins than Jest
    • Mitigation: Core functionality sufficient, ecosystem growing
  2. Newer Project

    • Less mature than Jest (started 2021)
    • Mitigation: Stable and actively maintained, 1.0 released
  3. Learning Curve

    • Developers must learn Vitest API (if unfamiliar with Jest)
    • Mitigation: API nearly identical to Jest
  4. 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

References