Skip to content

Testing Patterns

This guide covers testing patterns for the key areas of show.fm: Svelte 5 components, Hono API routes, and Cloudflare Workers.

Svelte 5 Component Testing

Basic Component Test

typescript
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Button from '../Button.svelte';

describe('Button', () => {
	it('should render with label', () => {
		render(Button, { props: { label: 'Click me' } });
		expect(screen.getByRole('button')).toHaveTextContent('Click me');
	});

	it('should handle click events', async () => {
		const handleClick = vi.fn();
		render(Button, { props: { onclick: handleClick } });

		await fireEvent.click(screen.getByRole('button'));

		expect(handleClick).toHaveBeenCalledTimes(1);
	});
});

Testing Reactive State ($state)

Svelte 5 Runes

Runes ($state, $derived, $effect) are compile-time transforms. You cannot access $state values directly in tests. Instead, test through DOM assertions.

typescript
// Component: Counter.svelte
// let count = $state(0);

it('should update counter on click', async () => {
	render(Counter);
	const button = screen.getByRole('button', { name: /increment/i });

	expect(screen.getByText('Count: 0')).toBeInTheDocument();

	await fireEvent.click(button);

	// Assert state change through DOM
	expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Testing Props with $props()

typescript
// Component uses: let { title, subtitle = 'Default' } = $props();

it('should render with required and optional props', () => {
	render(Header, {
		props: {
			title: 'Welcome'
			// subtitle uses default
		}
	});

	expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
	expect(screen.getByText('Default')).toBeInTheDocument();
});

it('should override optional props', () => {
	render(Header, {
		props: {
			title: 'Welcome',
			subtitle: 'Custom subtitle'
		}
	});

	expect(screen.getByText('Custom subtitle')).toBeInTheDocument();
});

Testing Callback Props

typescript
it('should call onSubmit with form data', async () => {
	const handleSubmit = vi.fn();
	render(BookingForm, {
		props: { onsubmit: handleSubmit }
	});

	await fireEvent.input(screen.getByLabelText('Name'), {
		target: { value: 'John Doe' }
	});
	await fireEvent.click(screen.getByRole('button', { name: 'Submit' }));

	expect(handleSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'John Doe' }));
});

Accessibility Testing

typescript
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Form Accessibility', () => {
	it('should have no WCAG violations', async () => {
		const { container } = render(BookingForm);
		const results = await axe(container);
		expect(results).toHaveNoViolations();
	});

	it('should be keyboard navigable', async () => {
		render(DropdownMenu);
		const trigger = screen.getByRole('button', { name: 'Options' });

		// Focus trigger
		trigger.focus();
		expect(document.activeElement).toBe(trigger);

		// Open with Enter
		await fireEvent.keyDown(trigger, { key: 'Enter' });
		expect(screen.getByRole('menu')).toBeVisible();

		// Navigate with arrow keys
		await fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
		expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
	});

	it('should have proper ARIA attributes', () => {
		render(LoadingButton, { props: { loading: true } });

		expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
		expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
	});
});

Testing Async Components

typescript
import { waitFor } from '@testing-library/svelte';

it('should show loading state then data', async () => {
	render(PodcastList);

	// Initial loading state
	expect(screen.getByText('Loading...')).toBeInTheDocument();

	// Wait for data to load
	await waitFor(() => {
		expect(screen.getByText('Test Podcast')).toBeInTheDocument();
	});

	// Loading state should be gone
	expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

Hono API Route Testing

Basic Route Test

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { testClient } from 'hono/testing';
import { app } from '$api/index';
import { createMockSupabaseClient } from '$test';

// Mock Supabase
vi.mock('$lib/supabase/server', () => ({
	createServerClient: () => createMockSupabaseClient()
}));

describe('GET /api/health', () => {
	it('should return 200 OK', async () => {
		const res = await testClient(app).api.health.$get();

		expect(res.status).toBe(200);
		const data = await res.json();
		expect(data).toHaveProperty('status', 'ok');
	});
});

Testing Authentication

typescript
describe('Protected Routes', () => {
	it('should return 401 without auth header', async () => {
		const res = await testClient(app).api.podcasts.$get();
		expect(res.status).toBe(401);
	});

	it('should return 401 with invalid token', async () => {
		vi.mocked(createMockSupabaseClient).mockReturnValue({
			...createMockSupabaseClient(),
			auth: {
				getUser: vi.fn().mockResolvedValue({
					data: { user: null },
					error: { message: 'Invalid token' }
				})
			}
		});

		const res = await testClient(app).api.podcasts.$get(
			{},
			{
				headers: { Authorization: 'Bearer invalid-token' }
			}
		);

		expect(res.status).toBe(401);
	});
});

Testing RLS Boundaries

typescript
describe('RLS Enforcement', () => {
	it('should return 403 for cross-tenant access', async () => {
		// Mock user A trying to access user B's podcast
		const mockClient = createMockSupabaseClient({
			from: {
				podcasts: {
					single: vi.fn().mockResolvedValue({
						data: null,
						error: { code: 'PGRST116', message: 'Row not found' }
					})
				}
			}
		});

		vi.mocked(createServerClient).mockReturnValue(mockClient);

		const res = await testClient(app).api.podcasts[':id'].$get({
			param: { id: 'other-users-podcast' }
		});

		expect(res.status).toBe(404); // Or 403 depending on your API design
	});
});

Testing Request Validation

typescript
describe('POST /api/e', () => {
	it('should validate required fields', async () => {
		const res = await testClient(app).api.episodes.$post({
			json: { title: '' } // Missing required fields
		});

		expect(res.status).toBe(400);
		const error = await res.json();
		expect(error.message).toContain('validation');
	});

	it('should create episode with valid data', async () => {
		const mockClient = createMockSupabaseClient({
			from: {
				episodes: {
					insert: vi.fn().mockReturnThis(),
					select: vi.fn().mockReturnThis(),
					single: vi.fn().mockResolvedValue({
						data: { id: 'ep-123', title: 'New Episode' },
						error: null
					})
				}
			}
		});

		vi.mocked(createServerClient).mockReturnValue(mockClient);

		const res = await testClient(app).api.episodes.$post({
			json: {
				title: 'New Episode',
				podcast_id: 'pod-123',
				description: 'A great episode'
			}
		});

		expect(res.status).toBe(201);
	});
});

Testing Shared Rate Limiting (Middleware + Route Wiring)

Use a two-layer pattern:

  1. Middleware contract test for src/api/middleware/rate-limit.ts
  2. Route-level wiring test on a real endpoint using the middleware
typescript
// src/api/middleware/__tests__/rate-limit.test.ts
describe('rateLimit middleware', () => {
	it('returns 429 and Retry-After when limit is exceeded', async () => {
		mockCreateClient.mockReturnValue({
			rpc: vi.fn().mockResolvedValue({
				data: {
					allowed: false,
					remaining: 0,
					max_requests: 2,
					reset_at: '2026-02-03T16:30:00.000Z',
					retry_after_seconds: 42
				},
				error: null
			})
		});

		const res = await app.request('/api/limited', {}, mockEnv);

		expect(res.status).toBe(429);
		expect(res.headers.get('Retry-After')).toBe('42');
	});
});
typescript
// src/api/routes/guests/__tests__/index.test.ts
describe('Guests routes rate limiting', () => {
	it('enforces rate limiting on POST /send-verification', async () => {
		mockCreateClient.mockReturnValue({
			rpc: vi.fn().mockResolvedValue({
				data: {
					allowed: false,
					remaining: 0,
					max_requests: 10,
					reset_at: '2026-02-03T16:30:00.000Z',
					retry_after_seconds: 30
				},
				error: null
			}),
			from: vi.fn()
		});

		const res = await guestsRoutes.request(req, undefined, mockEnv);

		expect(res.status).toBe(429);
		expect(res.headers.get('Retry-After')).toBe('30');
		expect(mockSendGuestVerificationEmail).not.toHaveBeenCalled();
	});
});

This catches both middleware regressions and accidental route de-wiring.

Testing Webhooks with Behavior Assertions

When testing webhooks, use behavior assertions to verify what data is actually written to the database, not just that methods were called. This catches real bugs like incorrect field values.

typescript
import Stripe from 'stripe';

// Hoisted mocks for argument capture
const { mockConstructEvent, mockSupabaseUpdate, mockSupabaseFrom } = vi.hoisted(() => ({
  mockConstructEvent: vi.fn(),
  mockSupabaseUpdate: vi.fn(),
  mockSupabaseFrom: vi.fn()
}));

// Mock Stripe
vi.mock('stripe', () => ({
  default: class MockStripe {
    webhooks = { constructEvent: mockConstructEvent };
    subscriptions = { retrieve: vi.fn() };
  }
}));

// Mock Supabase with argument capture
vi.mock('$lib/supabase/admin', () => ({
  createAdminClient: vi.fn(() => ({
    from: mockSupabaseFrom.mockImplementation((table) => ({
      update: mockSupabaseUpdate.mockImplementation((data) => ({
        eq: vi.fn().mockImplementation(() => ({
          select: vi.fn().mockResolvedValue({ data: [...], error: null })
        }))
      }))
    }))
  })),
  getTierFromPriceId: vi.fn((priceId) => {
    if (priceId.includes('professional')) return 'professional';
    return 'starter';
  })
}));

describe('Stripe Webhook', () => {
  const mockEvent: Stripe.Event = {
    id: 'evt_test_123',
    type: 'checkout.session.completed',
    data: {
      object: {
        id: 'cs_test_123',
        customer: 'cus_test_123',
        subscription: 'sub_test_123',
        metadata: { user_id: 'user-123', tier: 'starter' }
      } as Stripe.Checkout.Session
    }
  } as Stripe.Event;

  beforeEach(() => {
    vi.clearAllMocks();
    mockConstructEvent.mockReturnValue(mockEvent);
  });

  it('should update user profile with correct data', async () => {
    const res = await stripeWebhook.request(
      new Request('http://localhost/', {
        method: 'POST',
        headers: { 'stripe-signature': 'valid_sig' },
        body: JSON.stringify(mockEvent)
      }),
      undefined,
      mockEnv
    );

    expect(res.status).toBe(200);

    // Behavior assertions - verify WHAT was written, not just that it was called
    expect(mockSupabaseFrom).toHaveBeenCalledWith('user_profiles');
    expect(mockSupabaseUpdate).toHaveBeenCalledWith(
      expect.objectContaining({
        subscription_tier: 'starter',
        subscription_status: 'active',
        stripe_customer_id: 'cus_test_123'
      })
    );
  });

  it('should reject invalid signatures', async () => {
    mockConstructEvent.mockImplementation(() => {
      throw new Error('Invalid signature');
    });

    const res = await stripeWebhook.request(
      new Request('http://localhost/', {
        method: 'POST',
        headers: { 'stripe-signature': 'bad-sig' },
        body: 'invalid-body'
      }),
      undefined,
      mockEnv
    );

    expect(res.status).toBe(400);
    // Database should NOT be called for invalid signatures
    expect(mockSupabaseUpdate).not.toHaveBeenCalled();
  });
});

Behavior Assertions vs Mock Verification

Bad (Mock Verification Only):

typescript
expect(mockSupabaseUpdate).toHaveBeenCalled(); // Only checks IF called

Good (Behavior Assertion):

typescript
expect(mockSupabaseUpdate).toHaveBeenCalledWith(
	expect.objectContaining({
		subscription_tier: 'starter', // Verifies correct tier
		subscription_status: 'active' // Verifies correct status
	})
);

Behavior assertions catch bugs where the function was called but with wrong data.


Cloudflare Worker Testing

Worker Environment Setup

typescript
// @vitest-environment node

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
	createMockR2Bucket,
	createMockQueue,
	createMockHyperdrive,
	createMockMessage,
	createMockMessageBatch
} from '$test';
import handler from '../src/index';

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'
	};

	beforeEach(() => {
		vi.clearAllMocks();
	});

	// Tests here...
});

Testing Queue Consumers

typescript
describe('Queue Consumer', () => {
	it('should process and ack successful message', async () => {
		const message = createMockMessage({
			type: 'execute_rule',
			rule_id: 'rule-123',
			execution_id: 'exec-456',
			payload: { podcast_id: 'pod-789', trigger_type: 'booking.confirmed' }
		});

		const batch = createMockMessageBatch([message], 'automation-executions');

		await handler.queue(batch, mockEnv);

		expect(message.ack).toHaveBeenCalled();
		expect(message.retry).not.toHaveBeenCalled();
	});

	it('should retry on transient failure', async () => {
		const message = createMockMessage(
			{
				type: 'execute_rule',
				rule_id: 'rule-123'
			},
			2
		); // 2 attempts so far

		// Mock a transient error
		vi.mocked(mockEnv.HYPERDRIVE.connectionString).mockImplementation(() => {
			throw new Error('Connection timeout');
		});

		const batch = createMockMessageBatch([message]);

		await handler.queue(batch, mockEnv);

		expect(message.retry).toHaveBeenCalled();
		expect(message.ack).not.toHaveBeenCalled();
	});

	it('should move to DLQ after max retries', async () => {
		const message = createMockMessage(
			{
				type: 'execute_rule',
				rule_id: 'rule-123'
			},
			5
		); // Max retries reached

		// Simulate failure
		vi.spyOn(console, 'error').mockImplementation(() => {});

		const batch = createMockMessageBatch([message]);

		await handler.queue(batch, mockEnv);

		// After max retries, we ack to move to DLQ
		expect(message.ack).toHaveBeenCalled();
	});
});

Testing Cron Triggers

typescript
describe('Scheduled Handler', () => {
	it('should process scheduled jobs', async () => {
		const controller = {
			scheduledTime: new Date(),
			cron: '*/5 * * * *'
		};

		// Mock database response
		vi.mock('postgres', () => ({
			default: vi.fn(() => {
				const sql = vi
					.fn()
					.mockResolvedValue([
						{ id: 'job-1', trigger_type: 'time_based', scheduled_for: new Date() }
					]);
				sql.end = vi.fn();
				return sql;
			})
		}));

		await handler.scheduled(controller, mockEnv);

		expect(mockEnv.AUTOMATION_QUEUE.send).toHaveBeenCalled();
	});
});

Testing HTTP Fetch Handlers

typescript
describe('RSS Feed Worker', () => {
	it('should return valid RSS feed', async () => {
		const request = new Request('https://rss.cdn.media/0a15b8ab-6f3e-4b3a-9d21-3a1c2f4d5e6a');

		const response = await handler.fetch(request, mockEnv);

		expect(response.status).toBe(200);
		expect(response.headers.get('Content-Type')).toContain('application/rss+xml');

		const body = await response.text();
		expect(body).toContain('<?xml version="1.0"');
		expect(body).toContain('<rss');
	});

	it('should return 404 for unknown podcast', async () => {
		// Mock empty database response
		vi.mock('postgres', () => ({
			default: vi.fn(() => {
				const sql = vi.fn().mockResolvedValue([]);
				sql.end = vi.fn();
				return sql;
			})
		}));

		const request = new Request('https://rss.cdn.media/ffffffff-ffff-4fff-8fff-ffffffffffff');
		const response = await handler.fetch(request, mockEnv);

		expect(response.status).toBe(404);
	});

	it('should handle R2 storage errors gracefully', async () => {
		mockEnv.MEDIA_BUCKET.get = vi.fn().mockRejectedValue(new Error('R2 error'));

		const request = new Request('https://rss.cdn.media/0a15b8ab-6f3e-4b3a-9d21-3a1c2f4d5e6a');
		const response = await handler.fetch(request, mockEnv);

		// Should still return feed, just without media URLs
		expect(response.status).toBe(200);
	});
});

Security Testing Patterns

Input Validation

typescript
describe('Input Security', () => {
	it('should sanitize XSS in user input', async () => {
		const xssPayload = '<script>alert("xss")</script>';

		const res = await testClient(app).api.episodes.$post({
			json: {
				title: xssPayload,
				podcast_id: 'pod-123'
			}
		});

		const data = await res.json();
		expect(data.title).not.toContain('<script>');
	});

	it('should prevent SQL injection', async () => {
		const sqlPayload = "'; DROP TABLE episodes; --";

		const res = await testClient(app).api.episodes.$get({
			query: { search: sqlPayload }
		});

		// Should complete without error (RLS + parameterized queries protect)
		expect(res.status).not.toBe(500);
	});
});

Rate Limiting

typescript
describe('Rate Limiting', () => {
	it('should throttle excessive requests', async () => {
		const requests = Array(100)
			.fill(null)
			.map(() => testClient(app).api.health.$get());

		const responses = await Promise.all(requests);
		const rateLimited = responses.filter((r) => r.status === 429);

		expect(rateLimited.length).toBeGreaterThan(0);
	});
});

Best Practices

Do's

  • Test through public APIs (props, events, DOM)
  • Use screen queries for accessibility-friendly selectors
  • Mock external services at module boundaries
  • Test error states and edge cases
  • Include accessibility tests for UI components

Don'ts

  • Don't access $state or $derived directly
  • Don't test implementation details
  • Don't make real API calls in unit tests
  • Don't skip cleanup between tests
  • Don't use querySelector when Testing Library queries work

Test Organization

typescript
describe('Feature', () => {
	describe('Happy Path', () => {
		it('should work with valid input', () => {});
	});

	describe('Edge Cases', () => {
		it('should handle empty input', () => {});
		it('should handle special characters', () => {});
	});

	describe('Error States', () => {
		it('should show error on invalid input', () => {});
		it('should handle network failures', () => {});
	});

	describe('Accessibility', () => {
		it('should have no WCAG violations', async () => {});
		it('should be keyboard navigable', async () => {});
	});

	describe('Security', () => {
		it('should reject unauthenticated requests', () => {});
		it('should enforce RLS boundaries', () => {});
	});
});

Internal documentation - Not for public distribution