# Revve Testing — Full Reference ## 1. Configuration ### vitest.config.mts ```ts import { defineConfig } from 'vitest/config'; import path from 'path'; import dotenv from 'dotenv'; dotenv.config({ path: '.env' }); dotenv.config({ path: '.env.local', override: true }); export default defineConfig({ test: { globals: true, environment: 'jsdom', include: ['**/*.test.ts', '**/*.test.tsx'], exclude: ['node_modules', '.next', 'dist', 'tests/llm/**', 'tests/integration/**'], pool: 'forks', coverage: { provider: 'v8', include: ['libs/**', 'lib/**', 'action/**'], exclude: ['**/*.test.*', 'tests/**', '**/*.d.ts', '**/__tests__/**'], reporter: ['text', 'text-summary', 'json-summary', 'json'], reportOnFailure: true, thresholds: { lines: 10 }, }, }, resolve: { alias: { '@': path.resolve(__dirname), 'server-only': path.resolve(__dirname, 'tests/mocks/server-only.ts'), '@react-email/render': path.resolve(__dirname, 'tests/mocks/react-email-render.ts'), }, }, }); ``` Key points: - `globals: true` — no need to import `describe`, `it`, `expect` (but you can for explicitness) - `pool: 'forks'` — each test file runs in a separate process for isolation - Unit tests auto-discovered via `**/*.test.ts`, integration/LLM tests excluded from default run - Path alias `@/` resolves to project root, matching Next.js `tsconfig.json` - `server-only` and `@react-email/render` are aliased to stub modules --- ## 2. Test Types ### Unit Tests - **Location:** Co-located next to source file (e.g., `libs/stripe.test.ts` for `libs/stripe.ts`) - **Purpose:** Test individual functions with all external deps mocked - **Run with:** `pnpm test` or `pnpm test:unit` ### Integration Tests - **Location:** `tests/integration/*.integration.test.ts` - **Purpose:** Test real database operations, RLS policies, cross-table data flows - **Prerequisites:** Local Supabase running (`supabase start`) + migrations applied (`pnpm migrate up`) - **Run with:** `pnpm test:integration` ### LLM Evaluation Tests - **Location:** `tests/llm/*.test.ts` - **Purpose:** Evaluate LLM output quality across multiple models (OpenAI, Anthropic) - **Prerequisites:** API keys in `.env.test` (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) - **Run with:** `pnpm test:llm` --- ## 3. Commands ```bash # Unit tests pnpm test # All unit tests pnpm test:watch # Watch mode pnpm test:unit # Excludes tests/** entirely pnpm test:coverage # With v8 coverage # Integration tests (requires local Supabase) pnpm test:integration # LLM tests (requires API keys) pnpm test:llm # Run a single file pnpm vitest run libs/stripe.test.ts ``` --- ## 4. Unit Test Patterns ### 4.1 Basic Structure ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; // 1. Declare mock fns const mockFn = vi.fn(); // 2. Set up vi.mock() calls vi.mock('@/libs/supabase', () => ({ supabaseServiceRoleClient: { from: vi.fn() }, })); // 3. Import the module under test AFTER vi.mock() import { myFunction } from '@/libs/myModule'; describe('myFunction', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should do something', async () => { mockFn.mockResolvedValue({ data: 'test' }); const result = await myFunction(); expect(result).toBe('test'); }); }); ``` ### 4.2 Supabase Client Mocking The most common pattern — mock the chainable query builder: ```ts const mockSingle = vi.fn(); const mockEq = vi.fn().mockReturnValue({ single: mockSingle }); const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); const mockUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ data: null, error: null }), }); const mockInsert = vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { id: 'new-1' }, error: null }), }), }); vi.mock('@/libs/supabase', () => ({ supabaseServiceRoleClient: { from: vi.fn().mockImplementation(() => ({ select: mockSelect, update: mockUpdate, insert: mockInsert, })), }, })); ``` For multi-table mocking, use a routing `from()`: ```ts const { mockInsert, mockFrom } = vi.hoisted(() => { const mockInsert = vi.fn().mockReturnValue({ error: null }); const mockFrom = vi.fn().mockImplementation((table: string) => { if (table === 'chat_messages') return { insert: mockInsert }; if (table === 'contacts') { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ single: vi.fn() }), }), }; } return {}; }); return { mockInsert, mockFrom }; }); vi.mock('@/libs/supabase', () => ({ supabaseServiceRoleClient: { from: mockFrom }, })); ``` ### 4.3 External Service Constructor Mocks (Stripe, Resend, BullMQ) **CRITICAL: Must use `function()`, not arrow functions.** Vitest 4.x throws when calling `new` on arrow function mocks. #### Stripe ```ts const mockSessionsCreate = vi.fn(); vi.mock('stripe', () => { return { default: vi.fn().mockImplementation(function () { return { checkout: { sessions: { create: mockSessionsCreate } }, webhooks: { constructEvent: vi.fn() }, }; }), }; }); ``` #### Resend ```ts const resendSendMock = vi.fn(); vi.mock('resend', () => ({ Resend: vi.fn().mockImplementation(function () { return { emails: { send: resendSendMock } }; }), })); ``` #### BullMQ Queue ```ts const queueAddMock = vi.fn(); vi.mock('bullmq', () => ({ Queue: vi.fn().mockImplementation(function () { return { add: queueAddMock }; }), })); ``` ### 4.4 `vi.hoisted()` for Variables Used in `vi.mock()` Factories When mock variables need to be referenced inside `vi.mock()` factory functions, use `vi.hoisted()` to ensure they are declared before hoisting occurs: ```ts const { mockFrom, mockConstructEvent, mockHeadersGet, } = vi.hoisted(() => { const mockFrom = vi.fn().mockReturnValue({ update: vi.fn() }); const mockConstructEvent = vi.fn(); const mockHeadersGet = vi.fn(); return { mockFrom, mockConstructEvent, mockHeadersGet }; }); vi.mock('@/libs/supabase', () => ({ supabaseServiceRoleClient: { from: mockFrom }, })); vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue({ get: mockHeadersGet }), })); ``` ### 4.5 next/headers Mocking ```ts const mockHeadersGet = vi.fn(); vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue({ get: mockHeadersGet }), })); // In beforeEach: mockHeadersGet.mockReturnValue('some-header-value'); ``` ### 4.6 Testing with `vi.resetModules()` (Environment-Dependent Code) When the module under test reads `process.env` at import time, use dynamic imports with `vi.resetModules()`: ```ts beforeEach(() => { vi.resetModules(); delete process.env.EMAIL_TRANSPORT; }); it('uses Resend by default', async () => { process.env.RESEND_API_KEY = 'resend-api-key'; const { sendEmail } = await import('@/libs/email/transport'); await sendEmail({ from: 'a@b.com', to: 'c@d.com', subject: 'Test', html: '

Hi

' }); expect(resendSendMock).toHaveBeenCalled(); }); ``` ### 4.7 Pure Function Tests (No Mocking) For pure functions, no mocking needed — just import and test: ```ts import { sanitizeContactPayload } from '@/libs/contact'; describe('sanitizeContactPayload', () => { it('should lowercase valid email', () => { const result = sanitizeContactPayload({ name: 'Test', email: 'JOHN@EXAMPLE.COM' }); expect(result.email).toBe('john@example.com'); }); it('should reject invalid email format', () => { expect(() => sanitizeContactPayload({ name: 'Test', email: 'not-an-email', phoneNumber: '+1' })) .toThrow('email is invalid'); }); }); ``` --- ## 5. Integration Test Patterns ### 5.1 Setup Utilities (`tests/integration/setup.ts`) Provides factory functions for test data: | Function | Purpose | |---------------------------|-----------------------------------------------------| | `createTestClient()` | Service-role Supabase client (bypasses RLS) | | `createTestIds()` | Generate UUIDs for test data | | `createTestAuthUser()` | Create a real auth user via Admin API | | `createUserClient()` | Supabase client authenticated as a user (respects RLS) | | `createAnonClient()` | Unauthenticated client (for RLS testing) | | `createTestTeam()` | Insert a team record | | `addUserToTeam()` | Insert a team_users record | | `createTestChatBot()` | Insert a chat_bot record | | `createTestCallBot()` | Insert a call_bot record | | `createLockFields()` | Generate lock metadata for draft records | | `waitForDatabase()` | Retry until DB is responsive (useful in CI) | ### 5.2 Integration Test Structure ```ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createTestClient, createTestIds, createTestTeam, addUserToTeam, createTestAuthUser, createUserClient, createAnonClient, TestIds, clearUserClientCache, } from './setup'; import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@/types'; describe('Feature Integration Tests', () => { let serviceClient: SupabaseClient; let ids: TestIds; beforeAll(async () => { serviceClient = createTestClient(); ids = createTestIds(); // Create auth user const { userId, email } = await createTestAuthUser(serviceClient, ids.testId); ids.userId = userId; ids.userEmail = email; // Create team + membership await createTestTeam(serviceClient, ids); await addUserToTeam(serviceClient, ids.teamId, ids.userId, 'owner'); }); afterAll(async () => { clearUserClientCache(); // Clean up in reverse dependency order await serviceClient.from('team_users').delete().eq('team_id', ids.teamId); await serviceClient.from('teams').delete().eq('id', ids.teamId); if (ids.userId) await serviceClient.auth.admin.deleteUser(ids.userId); }); it('should allow team member to read team data', async () => { const userClient = await createUserClient(ids.userEmail); const { data, error } = await userClient .from('teams') .select('id, name') .eq('id', ids.teamId) .single(); expect(error).toBeNull(); expect(data?.name).toContain('Test Team'); }); it('should NOT allow anon user to read team data', async () => { const anonClient = createAnonClient(); const { data } = await anonClient .from('teams') .select('id') .eq('id', ids.teamId); expect(data).toEqual([]); }); }); ``` ### 5.3 RLS Testing Pattern Use three client types to verify row-level security: 1. **Service client** (`createTestClient()`) — bypasses RLS, used for setup/teardown 2. **User client** (`createUserClient(email)`) — authenticated, respects RLS 3. **Anon client** (`createAnonClient()`) — unauthenticated, respects RLS ```ts // Service role can read everything const { data: adminData } = await serviceClient.from('team_invitations').select('*'); expect(adminData?.length).toBeGreaterThan(0); // Authenticated user can only see their team's data const userClient = await createUserClient(ids.userEmail); const { data: userData } = await userClient.from('team_invitations').select('*').eq('team_id', ids.teamId); expect(userData?.length).toBeGreaterThan(0); // Other user cannot see data const otherClient = await createUserClient(otherUserEmail); const { data: otherData } = await otherClient.from('team_invitations').select('*').eq('id', ids.invitationId).single(); expect(otherData).toBeNull(); // Anon user gets empty results const anonClient = createAnonClient(); const { data: anonData } = await anonClient.from('team_invitations').select('*').limit(10); expect(anonData).toEqual([]); ``` ### 5.4 Cleanup Order Always delete in reverse dependency order to avoid foreign key violations: ```ts afterAll(async () => { clearUserClientCache(); // Children first await serviceClient.from('chat_bot_drafts').delete().eq('chat_bot_id', ids.chatBotId); await serviceClient.from('chat_bots').delete().eq('id', ids.chatBotId); await serviceClient.from('team_users').delete().eq('team_id', ids.teamId); await serviceClient.from('teams').delete().eq('id', ids.teamId); // Auth users last if (ids.userId) await serviceClient.auth.admin.deleteUser(ids.userId); }); ``` --- ## 6. LLM Test Patterns ### 6.1 Multi-Model Configuration ```ts import { LLMConfig } from '@/tests/llm/helper'; const llmConfigs: LLMConfig[] = [ { provider: 'openai', model: 'o4-mini' }, { provider: 'claude', model: 'claude-4-sonnet-20250514' }, ]; ``` ### 6.2 Test Case Structure Test cases are defined in separate `*-cases.ts` files: ``` tests/llm/ decision-cases.ts # Test case definitions decision.test.ts # Test runner thread-analyze-cases.ts thread-analyze.test.ts helper.ts # Shared types and utilities ``` ### 6.3 Assertion Tracking LLM tests track per-field assertion results for detailed reporting: ```ts interface FieldResult { field: string; passed: boolean; expected: any; actual: any; message: string; } // Collect results per test case per model const allTestResults: TestResult[] = []; ``` ### 6.4 Result Storage Results are written to three formats in `afterAll`: 1. **Detailed JSON** — full assertion data per test case 2. **Summary JSON** — aggregated pass rates by model 3. **Markdown report** — human-readable with failure details If Supabase credentials are available (`SUPABASE_URL`, `SUPABASE_SERVICE_KEY`), results are also saved to: - `test.llm_test_results` table (summary) - `test.llm_test_case_results` table (individual cases) - `test-results` storage bucket (JSON + markdown files) ### 6.5 Timeout LLM tests use extended timeouts: ```ts it('should correctly analyze', async () => { // ... }, 180000); // 3 minutes per test case ``` --- ## 7. Shared Mocks ### `tests/mocks/server-only.ts` Stubs the `server-only` package that Next.js uses to prevent client imports: ```ts export {}; ``` Aliased in `vitest.config.mts` so any `import 'server-only'` resolves to this empty module. ### `tests/mocks/react-email-render.ts` Provides a configurable mock for `@react-email/render`: ```ts declare global { var __emailRenderMock: ((...args: any[]) => any) | undefined; } export const render = (...args: any[]) => { if (!globalThis.__emailRenderMock) { throw new Error('Email render mock not configured.'); } return globalThis.__emailRenderMock(...args); }; ``` Usage in tests: ```ts const renderMock = vi.fn(); globalThis.__emailRenderMock = (...args: any[]) => renderMock(...args); // In beforeEach: renderMock.mockReset(); globalThis.__emailRenderMock = (...args) => renderMock(...args); ``` --- ## 8. CI/CD Workflows ### 8.1 Build Check (`.github/workflows/build-check.yml`) Triggers on: push to any branch except `main`, PRs to `main`. Three parallel jobs: | Job | What it does | |-------------------|-------------------------------------------------------| | `unit-test` | Runs `pnpm vitest --coverage.enabled true`, posts coverage comment on PR | | `integration-test`| Calls reusable `integration-tests.yml` workflow | | `build` | Runs `pnpm run build` with placeholder env vars | Coverage PR comments use [`davelosert/vitest-coverage-report-action@v2`](https://github.com/davelosert/vitest-coverage-report-action). ### 8.2 Integration Tests (`.github/workflows/integration-tests.yml`) Reusable workflow (`workflow_call`). Steps: 1. Start local Supabase (excluding studio/imgproxy/edge-runtime/logflare/vector) 2. Install dependencies in parallel with Supabase startup 3. Extract Supabase credentials from `supabase status --output json` 4. Run database migrations (`pnpm migrate up`) 5. Execute `pnpm test:integration` 6. Stop Supabase (`supabase stop --no-backup`) ### 8.3 LLM Tests (`.github/workflows/run-llm-tests.yml`) Triggers on: `workflow_dispatch` (manual only). Steps: 1. Create `.env.test` from GitHub secrets 2. Run `pnpm test:llm` 3. Parse `test-results/` directory for summary JSON files 4. Upload results as GitHub artifacts 5. Send Slack notification with per-model pass rates --- ## 9. Common Pitfalls ### 9.1 Arrow Functions in Constructor Mocks **Wrong** — Vitest 4.x throws `TypeError: Class constructor X cannot be invoked without 'new'`: ```ts // BAD vi.mock('stripe', () => ({ default: vi.fn(() => ({ checkout: { sessions: { create: vi.fn() } } })), })); ``` **Correct:** ```ts // GOOD vi.mock('stripe', () => ({ default: vi.fn().mockImplementation(function () { return { checkout: { sessions: { create: vi.fn() } } }; }), })); ``` ### 9.2 Hoisting — Variables Not Available in `vi.mock()` Factory **Wrong** — `mockFn` is `undefined` inside the factory because `vi.mock()` is hoisted above variable declarations: ```ts // BAD const mockFn = vi.fn(); vi.mock('@/libs/foo', () => ({ bar: mockFn, // undefined at hoist time! })); ``` **Correct** — use `vi.hoisted()`: ```ts // GOOD const { mockFn } = vi.hoisted(() => { const mockFn = vi.fn(); return { mockFn }; }); vi.mock('@/libs/foo', () => ({ bar: mockFn, })); ``` **Alternative** — declare mock fns at module top-level (works when not using `vi.hoisted`): ```ts // ALSO GOOD — top-level const declarations are available to hoisted vi.mock() // ONLY when the variable is declared with const/let at the module scope const mockFn = vi.fn(); vi.mock('@/libs/foo', () => ({ bar: mockFn, })); ``` The key rule: if the variable is assigned by a call to `vi.fn()` at the top of the file, it is available. If it depends on other runtime values, use `vi.hoisted()`. ### 9.3 Import Order Always import the module under test **after** all `vi.mock()` calls: ```ts // 1. Imports from vitest import { describe, it, expect, vi } from 'vitest'; // 2. Mock declarations const mockFn = vi.fn(); vi.mock('@/libs/dep', () => ({ dep: mockFn })); // 3. Import under test (MUST come after vi.mock) import { myFunction } from '@/libs/myModule'; ``` ### 9.4 Forgetting `vi.clearAllMocks()` in `beforeEach` Always reset mocks between tests to avoid cross-test pollution: ```ts beforeEach(() => { vi.clearAllMocks(); }); ``` ### 9.5 Integration Test Environment Variables Integration tests require these env vars (set automatically in CI): - `NEXT_PUBLIC_SUPABASE_URL` or `SERVICE_ROLE_LOCAL_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_ANON_KEY` - `SUPABASE_SERVICE_ROLE_KEY` - `DATABASE_URL` (for migrations) Locally, these come from your `.env` / `.env.local` files. ### 9.6 Test Results Directory LLM tests write to `test-results/` which is gitignored. The directory is created automatically by the test runner.