Revve Testing — Full Reference
1. Configuration
vitest.config.mts
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 importdescribe,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.jstsconfig.json server-onlyand@react-email/renderare aliased to stub modules
2. Test Types
Unit Tests
- Location: Co-located next to source file (e.g.,
libs/stripe.test.tsforlibs/stripe.ts) - Purpose: Test individual functions with all external deps mocked
- Run with:
pnpm testorpnpm 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
# 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
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:
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():
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
const mockSessionsCreate = vi.fn();
vi.mock('stripe', () => {
return {
default: vi.fn().mockImplementation(function () {
return {
checkout: { sessions: { create: mockSessionsCreate } },
webhooks: { constructEvent: vi.fn() },
};
}),
};
});
Resend
const resendSendMock = vi.fn();
vi.mock('resend', () => ({
Resend: vi.fn().mockImplementation(function () {
return { emails: { send: resendSendMock } };
}),
}));
BullMQ Queue
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:
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
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():
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: '<p>Hi</p>' });
expect(resendSendMock).toHaveBeenCalled();
});
4.7 Pure Function Tests (No Mocking)
For pure functions, no mocking needed — just import and test:
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
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<Database>;
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:
- Service client (
createTestClient()) — bypasses RLS, used for setup/teardown - User client (
createUserClient(email)) — authenticated, respects RLS - Anon client (
createAnonClient()) — unauthenticated, respects RLS
// 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:
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
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:
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:
- Detailed JSON — full assertion data per test case
- Summary JSON — aggregated pass rates by model
- 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_resultstable (summary)test.llm_test_case_resultstable (individual cases)test-resultsstorage bucket (JSON + markdown files)
6.5 Timeout
LLM tests use extended timeouts:
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:
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:
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:
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.
8.2 Integration Tests (.github/workflows/integration-tests.yml)
Reusable workflow (workflow_call). Steps:
- Start local Supabase (excluding studio/imgproxy/edge-runtime/logflare/vector)
- Install dependencies in parallel with Supabase startup
- Extract Supabase credentials from
supabase status --output json - Run database migrations (
pnpm migrate up) - Execute
pnpm test:integration - Stop Supabase (
supabase stop --no-backup)
8.3 LLM Tests (.github/workflows/run-llm-tests.yml)
Triggers on: workflow_dispatch (manual only). Steps:
- Create
.env.testfrom GitHub secrets - Run
pnpm test:llm - Parse
test-results/directory for summary JSON files - Upload results as GitHub artifacts
- 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':
// BAD
vi.mock('stripe', () => ({
default: vi.fn(() => ({ checkout: { sessions: { create: vi.fn() } } })),
}));
Correct:
// 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:
// BAD
const mockFn = vi.fn();
vi.mock('@/libs/foo', () => ({
bar: mockFn, // undefined at hoist time!
}));
Correct — use vi.hoisted():
// 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):
// 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:
// 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:
beforeEach(() => {
vi.clearAllMocks();
});
9.5 Integration Test Environment Variables
Integration tests require these env vars (set automatically in CI):
NEXT_PUBLIC_SUPABASE_URLorSERVICE_ROLE_LOCAL_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYDATABASE_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.