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
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.
// 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()
// 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
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
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
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
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
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
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
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:
- Middleware contract test for
src/api/middleware/rate-limit.ts - Route-level wiring test on a real endpoint using the middleware
// 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');
});
});// 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.
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):
expect(mockSupabaseUpdate).toHaveBeenCalled(); // Only checks IF calledGood (Behavior Assertion):
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
// @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
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
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
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
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
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
screenqueries 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
$stateor$deriveddirectly - Don't test implementation details
- Don't make real API calls in unit tests
- Don't skip cleanup between tests
- Don't use
querySelectorwhen Testing Library queries work
Test Organization
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', () => {});
});
});Related Documentation
- Testing Strategy - Philosophy and conventions
- Domain Map - Business logic test coverage tracking
- Mock Factories - Complete mock library reference
- Testing Infrastructure - Stack overview and setup