Mock Factories Reference
The $test/utils module provides comprehensive mock factories for all external services and Cloudflare bindings used in show.fm.
Supabase Client
createMockSupabaseClient(overrides?)
Creates a fully-functional mock Supabase client with chainable query methods.
import { createMockSupabaseClient, mockUser } from '$test';
const client = createMockSupabaseClient();Default Mock User
// Default user returned by auth.getUser()
{
id: 'user-123',
email: '[email protected]',
user_metadata: { full_name: 'Test User' }
}Customizing Auth
const client = createMockSupabaseClient({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: 'custom-user', email: '[email protected]' } },
error: null
})
}
});Customizing Table Responses
const client = createMockSupabaseClient({
from: {
podcasts: {
single: vi.fn().mockResolvedValue({
data: { id: 'pod-123', name: 'My Podcast' },
error: null
})
},
episodes: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'ep-123', title: 'Episode 1' },
error: null
})
}
}
});Supported Query Methods
All Supabase query chain methods are mocked:
| Category | Methods |
|---|---|
| CRUD | select, insert, update, delete, upsert |
| Filters | eq, neq, gt, gte, lt, lte, like, ilike, is, in, contains, containedBy, range, textSearch, filter, not, or, match |
| Modifiers | order, limit, offset, single, maybeSingle, throwOnError |
FTS Query Builder Mock
For testing PostgreSQL Full-Text Search queries, use a specialized chainable mock:
// Create chainable FTS mock with customizable response
function createMockQueryBuilder(resolvedData: unknown[] = [], count = 0): MockQueryBuilder {
const builder = {
select: vi.fn().mockReturnThis(),
textSearch: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue({
data: resolvedData,
count,
error: null
})
};
return builder;
}
// Create mock Supabase client with FTS support
function createFtsMockClient(builder: MockQueryBuilder) {
return {
from: vi.fn().mockReturnValue(builder)
} as unknown as SupabaseClient<Database>;
}Usage Example
import { ftsQuery } from '$lib/search/fts-search';
describe('FTS Queries', () => {
it('should apply textSearch with websearch type', async () => {
const mockData = [{ id: '1', title: 'Test Episode' }];
const mockBuilder = createMockQueryBuilder(mockData, 1);
const mockSupabase = createFtsMockClient(mockBuilder);
await ftsQuery(mockSupabase, 'episodes', { query: 'test' });
// Verify FTS was called correctly
expect(mockBuilder.textSearch).toHaveBeenCalledWith('fts', 'test', {
type: 'websearch',
config: 'english'
});
});
it('should apply date range filters', async () => {
const mockBuilder = createMockQueryBuilder([], 0);
const mockSupabase = createFtsMockClient(mockBuilder);
const from = new Date('2024-01-01');
const to = new Date('2024-12-31');
await ftsQuery(mockSupabase, 'episodes', {
dateRange: { published_at: { from, to } }
});
expect(mockBuilder.gte).toHaveBeenCalledWith('published_at', from.toISOString());
expect(mockBuilder.lte).toHaveBeenCalledWith('published_at', to.toISOString());
});
it('should handle pagination correctly', async () => {
const mockBuilder = createMockQueryBuilder([], 50);
const mockSupabase = createFtsMockClient(mockBuilder);
const result = await ftsQuery(mockSupabase, 'episodes', {
pagination: { page: 3, perPage: 10 }
});
// Page 3, 10 per page → range(20, 29)
expect(mockBuilder.range).toHaveBeenCalledWith(20, 29);
expect(result.totalPages).toBe(5);
});
});Storage Mock
const { storage } = createMockSupabaseClient();
// Available methods
await storage.from('bucket').upload('path', file);
await storage.from('bucket').download('path');
await storage.from('bucket').remove(['path']);
storage.from('bucket').getPublicUrl('path');Cloudflare R2 Bucket
createMockR2Bucket(overrides?)
Creates an in-memory R2 bucket backed by a Map for realistic storage testing.
import { createMockR2Bucket } from '$test';
const bucket = createMockR2Bucket();Basic Operations
// Upload
await bucket.put('podcasts/123/cover.jpg', imageBuffer, {
httpMetadata: { contentType: 'image/jpeg' },
customMetadata: { podcastId: '123' }
});
// Download
const object = await bucket.get('podcasts/123/cover.jpg');
const data = await object?.arrayBuffer();
// Check existence
const head = await bucket.head('podcasts/123/cover.jpg');
if (head) {
console.log('Size:', head.size);
}
// Delete
await bucket.delete('podcasts/123/cover.jpg');
// Or batch delete
await bucket.delete(['file1.jpg', 'file2.jpg']);
// List with prefix
const { objects } = await bucket.list({ prefix: 'podcasts/123/' });Available Methods
| Method | Description |
|---|---|
put(key, value, options) | Store data with optional metadata |
get(key) | Retrieve object (returns null if not found) |
head(key) | Get metadata without body |
delete(key | keys[]) | Remove one or more objects |
list(options) | List objects with prefix/limit/cursor |
createMultipartUpload() | Start multipart upload |
resumeMultipartUpload() | Resume multipart upload |
Cloudflare Queues
Producer: createMockQueue<T>()
Creates a mock queue for testing message producers in the main app.
import { createMockQueue } from '$test';
interface AutomationMessage {
type: 'execute_rule';
rule_id: string;
execution_id: string;
}
const queue = createMockQueue<AutomationMessage>();
// Send message
await queue.send({
type: 'execute_rule',
rule_id: 'rule-123',
execution_id: 'exec-456'
});
// Assertions
expect(queue.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'execute_rule' }));
expect(queue._messages).toHaveLength(1);Batch Sending
await queue.sendBatch([
{ body: { type: 'execute_rule', rule_id: 'rule-1' } },
{ body: { type: 'execute_rule', rule_id: 'rule-2' } }
]);
expect(queue._messages).toHaveLength(2);Consumer: createMockMessage<T>(body, attempts?)
Creates a mock message for testing queue consumer workers.
import { createMockMessage, createMockMessageBatch } from '$test';
const message = createMockMessage(
{
type: 'execute_rule',
rule_id: 'rule-123'
},
1
); // attempts = 1
// Test your handler
await workerHandler.queue(createMockMessageBatch([message]), mockEnv);
// Verify acknowledgment
expect(message.ack).toHaveBeenCalled();
expect(message.retry).not.toHaveBeenCalled();Message Properties
| Property | Type | Description |
|---|---|---|
id | string | Unique message ID |
body | T | Message payload |
timestamp | Date | When message was created |
attempts | number | Number of delivery attempts |
ack() | vi.fn() | Spy for successful processing |
retry() | vi.fn() | Spy for retry requests |
Batch: createMockMessageBatch<T>(messages, queueName?)
Creates a message batch for testing queue consumer handlers.
const batch = createMockMessageBatch(
[createMockMessage({ rule_id: 'rule-1' }), createMockMessage({ rule_id: 'rule-2' })],
'automation-executions'
);
// Test handler
await workerHandler.queue(batch, mockEnv);
// Batch-level operations
batch.ackAll();
batch.retryAll();Cloudflare Hyperdrive
createMockHyperdrive()
Creates a mock Hyperdrive binding for database connections.
import { createMockHyperdrive } from '$test';
const mockEnv = {
HYPERDRIVE: createMockHyperdrive(),
AUTOMATION_QUEUE: createMockQueue(),
MEDIA_BUCKET: createMockR2Bucket()
};Returns:
{
connectionString: process.env.TEST_DATABASE_URL ||
'postgresql://test:test@localhost:5432/test_db';
}Database Mocking
For unit tests, mock the postgres client directly instead of making real database calls:
vi.mock('postgres', () => {
const mockSql = vi.fn((strings, ...values) => {
if (strings.join('?').includes('automation_rules')) {
return Promise.resolve([{ id: 'rule-123', is_enabled: true }]);
}
return Promise.resolve([]);
});
mockSql.end = vi.fn();
return { default: vi.fn(() => mockSql) };
});Stripe Objects
mockStripeObjects.checkoutSession(overrides?)
Creates a mock Stripe Checkout Session.
import { mockStripeObjects } from '$test';
const session = mockStripeObjects.checkoutSession({
customer: 'cus_456',
metadata: { tier: 'pro' }
});
// Default values
{
id: 'cs_test_123',
url: 'https://checkout.stripe.com/pay/cs_test_123',
customer: 'cus_test_123',
subscription: 'sub_test_123',
metadata: { user_id: 'user-123', tier: 'starter' }
}mockStripeObjects.subscription(overrides?)
Creates a mock Stripe Subscription.
const subscription = mockStripeObjects.subscription({
status: 'past_due'
});
// Default values
{
id: 'sub_test_123',
status: 'active',
items: { data: [{ price: { id: 'price_starter' } }] }
}Webhook Test Mock Pattern
For webhook handler tests, use hoisted mocks with mockImplementation to capture arguments for behavior assertions:
// Hoisted mocks ensure availability when vi.mock() is processed
const { mockConstructEvent, mockSubscriptionsRetrieve, mockSupabaseUpdate, mockSupabaseFrom } =
vi.hoisted(() => ({
mockConstructEvent: vi.fn(),
mockSubscriptionsRetrieve: vi.fn(),
mockSupabaseUpdate: vi.fn(),
mockSupabaseFrom: vi.fn()
}));
// Mock Stripe SDK with class
vi.mock('stripe', () => ({
default: class MockStripe {
webhooks = { constructEvent: mockConstructEvent };
subscriptions = { retrieve: mockSubscriptionsRetrieve };
}
}));
// Mock Supabase admin client with argument capture
vi.mock('$lib/supabase/admin', () => ({
createAdminClient: vi.fn(() => ({
from: mockSupabaseFrom.mockImplementation((table: string) => ({
update: mockSupabaseUpdate.mockImplementation((data: Record<string, unknown>) => ({
eq: vi.fn().mockImplementation((column: string, value: string) => ({
select: vi.fn().mockResolvedValue({
data: [{ id: 'user-123', ...data }],
error: null
})
}))
})),
select: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ data: null, error: null })
})
}))
})),
getTierFromPriceId: vi.fn((priceId: string) => {
if (priceId.includes('professional') || priceId.includes('pro')) return 'professional';
if (priceId.includes('enterprise')) return 'enterprise';
return 'starter';
})
}));Behavior Assertions
// Verify correct table was accessed
expect(mockSupabaseFrom).toHaveBeenCalledWith('user_profiles');
// Verify correct data was written (not just that update was called)
expect(mockSupabaseUpdate).toHaveBeenCalledWith(
expect.objectContaining({
subscription_tier: 'starter',
subscription_status: 'active',
stripe_customer_id: 'cus_test_123'
})
);Important
The Stripe webhook handler does NOT store stripe_subscription_id in the database. Test assertions should not expect this field.
Resend Email
mockResendSuccess(emailId?)
Mocks a successful Resend API response.
import { mockResendSuccess } from '$test';
beforeEach(() => {
mockResendSuccess('email-abc');
});
it('should send email', async () => {
await sendEmail({ to: '[email protected]', subject: 'Test' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.resend.com/emails',
expect.objectContaining({ method: 'POST' })
);
});mockResendError(status, message)
Mocks a failed Resend API response.
import { mockResendError } from '$test';
it('should handle rate limiting', async () => {
mockResendError(429, 'Rate limit exceeded');
await expect(sendEmail({})).rejects.toThrow('Rate limit');
});Test Data Factories
testData.podcast(overrides?)
import { testData } from '$test';
const podcast = testData.podcast({ name: 'Custom Name' });
// Default values
{
id: 'podcast-123',
name: 'Test Podcast',
slug: 'test-podcast',
description: 'A test podcast for unit tests',
created_at: '2026-01-14T...'
}testData.episode(overrides?)
const episode = testData.episode({ status: 'published' });
// Default values
{
id: 'episode-123',
title: 'Test Episode',
podcast_id: 'podcast-123',
description: 'A test episode',
status: 'draft',
created_at: '2026-01-14T...'
}testData.booking(overrides?)
const booking = testData.booking({ guest_email: '[email protected]' });
// Default values
{
id: 'booking-123',
guest_email: '[email protected]',
guest_name: 'Test Guest',
scheduled_time: '2026-01-15T...', // +1 day from now
status: 'pending'
}Component Test Helpers
waitForDomUpdate()
Waits for DOM updates after Svelte 5 state changes.
import { waitForDomUpdate } from '$test';
it('should update counter', async () => {
render(Counter);
fireEvent.click(screen.getByRole('button'));
await waitForDomUpdate();
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});createScreenHelpers(screen)
Type-safe wrapper for common Testing Library queries.
import { createScreenHelpers } from '$test';
import { screen } from '@testing-library/svelte';
const helpers = createScreenHelpers(screen);
// Type-safe queries
const button = helpers.getButton('Submit');
const input = helpers.getInput('Email');
const link = helpers.getLink('Learn more');
const heading = helpers.getHeading('Welcome');Complete Worker Environment Example
import {
createMockR2Bucket,
createMockQueue,
createMockHyperdrive,
createMockMessage,
createMockMessageBatch
} from '$test';
describe('Automation Executor Worker', () => {
const mockEnv = {
MEDIA_BUCKET: createMockR2Bucket(),
AUTOMATION_QUEUE: createMockQueue(),
HYPERDRIVE: createMockHyperdrive(),
PUBLIC_APP_URL: 'https://app.test.com',
RESEND_API_KEY: 'test-key'
};
it('should process automation message', async () => {
const message = createMockMessage({
type: 'execute_rule',
rule_id: 'rule-123',
execution_id: 'exec-456',
payload: { podcast_id: 'pod-789' }
});
const batch = createMockMessageBatch([message], 'automation-executions');
await workerHandler.queue(batch, mockEnv);
expect(message.ack).toHaveBeenCalled();
});
});Related Documentation
- Testing Strategy - Philosophy and conventions
- Domain Map - Business logic test coverage tracking
- Testing Patterns - Svelte 5, API, and Worker testing patterns
- Testing Infrastructure - Stack overview and setup