Best Practices

Foundation First Approach

The Strategic Test Planning Method

Start with complete test structure using describe and it.skip to plan comprehensively:

1import { describe, expect, it, vi } from 'vitest';
2import { render } from 'vitest-browser-svelte';
3import { page } from 'vitest/browser';
4
5describe('TodoManager Component', () => {
6	describe('Initial Rendering', () => {
7		it('should render empty state', async () => {
8			await render(TodoManager);
9
10			await expect
11				.element(page.getByText('No todos yet'))
12				.toBeInTheDocument();
13			await expect
14				.element(page.getByRole('list'))
15				.toHaveAttribute('aria-label', 'Todo list');
16		});
17
18		it.skip('should render with initial todos', async () => {
19			// TODO: Test with pre-populated data
20		});
21	});
22
23	describe('User Interactions', () => {
24		it('should add new todo', async () => {
25			await render(TodoManager);
26
27			const input = page.getByLabelText('New todo');
28			const add_button = page.getByRole('button', {
29				name: 'Add Todo',
30			});
31
32			await input.fill('Buy groceries');
33			await add_button.click();
34
35			await expect
36				.element(page.getByText('Buy groceries'))
37				.toBeInTheDocument();
38		});
39
40		it.skip('should edit existing todo', async () => {
41			// TODO: Test inline editing
42		});
43
44		it.skip('should delete todo', async () => {
45			// TODO: Test deletion flow
46		});
47	});
48
49	describe('Form Validation', () => {
50		it.skip('should prevent empty todo submission', async () => {
51			// TODO: Test validation rules
52		});
53
54		it.skip('should handle duplicate todos', async () => {
55			// TODO: Test duplicate prevention
56		});
57	});
58
59	describe('Accessibility', () => {
60		it.skip('should support keyboard navigation', async () => {
61			// TODO: Test tab order and shortcuts
62		});
63
64		it.skip('should announce changes to screen readers', async () => {
65			// TODO: Test ARIA live regions
66		});
67	});
68
69	describe('Edge Cases', () => {
70		it.skip('should handle network failures gracefully', async () => {
71			// TODO: Test offline scenarios
72		});
73
74		it.skip('should handle large todo lists', async () => {
75			// TODO: Test performance with 1000+ items
76		});
77	});
78});

Benefits of Foundation First

  • Complete picture: See all requirements upfront
  • Incremental progress: Remove .skip as you implement features
  • No forgotten tests: All edge cases planned from start
  • Team alignment: Everyone sees the testing scope
  • Flexible coverage: Implement tests as needed, not for arbitrary coverage metrics

Client-Server Alignment Strategy

The Problem with Heavy Mocking

The Issue: Server unit tests with heavy mocking can pass while production breaks due to client-server mismatches. Forms send data in one format, servers expect another, and mocked tests miss the disconnect.

Real-World Example: Your client sends FormData with field names like email, but your server expects user_email. Mocked tests pass because they don’t use real FormData objects, but production fails silently.

The Multi-Layer Testing Solution

This project demonstrates a strategic approach with minimal mocking:

1// ❌ BRITTLE: Heavy mocking hides client-server mismatches
2describe('User Registration - WRONG WAY', () => {
3	it('should register user', async () => {
4		const mock_request = {
5			formData: vi.fn().mockResolvedValue({
6				get: vi.fn().mockReturnValue('[email protected]'),
7			}),
8		};
9
10		// This passes but doesn't test real FormData behavior
11		const result = await register_user(mock_request);
12		expect(result.success).toBe(true);
13	});
14});
15
16// ✅ ROBUST: Real FormData objects catch actual mismatches
17describe('User Registration - CORRECT WAY', () => {
18	it('should register user with real FormData', async () => {
19		const form_data = new FormData();
20		form_data.append('email', '[email protected]');
21		form_data.append('password', 'secure123');
22
23		const request = new Request('http://localhost/register', {
24			method: 'POST',
25			body: form_data,
26		});
27
28		// Only mock external services (database), not data structures
29		vi.mocked(database.create_user).mockResolvedValue({
30			id: '123',
31			email: '[email protected]',
32		});
33
34		const result = await register_user(request);
35		expect(result.success).toBe(true);
36	});
37});

Four-Layer Testing Strategy

1. Shared Validation Logic

1// lib/validation/user-schema.ts
2export const user_registration_schema = {
3	email: { required: true, type: 'email' },
4	password: { required: true, min_length: 8 },
5};
6
7// Used in both client and server
8export const validate_user_registration = (data: FormData) => {
9	const email = data.get('email')?.toString();
10	const password = data.get('password')?.toString();
11
12	// Same validation logic everywhere
13	return {
14		email: validate_email(email),
15		password: validate_password(password),
16	};
17};

2. Real FormData/Request Objects in Server Tests

1describe('Registration API', () => {
2	it('should handle real form submission', async () => {
3		// Real FormData - catches field name mismatches
4		const form_data = new FormData();
5		form_data.append('email', '[email protected]');
6		form_data.append('password', 'secure123');
7
8		// Real Request object - catches header/method issues
9		const request = new Request('http://localhost/api/register', {
10			method: 'POST',
11			body: form_data,
12			headers: { 'Content-Type': 'multipart/form-data' },
13		});
14
15		// Only mock external services
16		vi.mocked(database.users.create).mockResolvedValue({
17			id: '123',
18			email: '[email protected]',
19		});
20
21		const response = await POST({ request });
22		expect(response.status).toBe(201);
23	});
24});

3. TypeScript Contracts

1// lib/types/user.ts
2export interface UserRegistration {
3	email: string;
4	password: string;
5}
6
7export interface UserResponse {
8	id: string;
9	email: string;
10	created_at: string;
11}
12
13// Both client and server use the same types
14// Compiler catches mismatches at build time

4. E2E Safety Net

1// src/routes/register/page.svelte.e2e.ts
2test('full registration flow', async ({ page }) => {
3	await page.goto('/register');
4
5	await page.getByLabelText('Email').fill('[email protected]');
6	await page.getByLabelText('Password').fill('secure123');
7	await page.getByRole('button', { name: 'Register' }).click();
8
9	// Tests the complete client-server integration
10	await expect(page.getByText('Welcome!')).toBeVisible();
11});

Benefits of This Approach

  • Fast unit test feedback with minimal mocking overhead
  • Confidence that client and server actually work together
  • Catches contract mismatches early in development
  • Reduces production bugs from client-server disconnects
  • Maintains test speed while improving reliability

What to Mock vs What to Keep Real

✅ Mock These (External Dependencies)

1// Database operations
2vi.mock('#lib/database.js', () => ({
3	users: {
4		create: vi.fn(),
5		find_by_email: vi.fn(),
6	},
7}));
8
9// External APIs
10vi.mock('#lib/email-service.js', () => ({
11	send_welcome_email: vi.fn(),
12}));
13
14// File system operations
15vi.mock('fs/promises', () => ({
16	writeFile: vi.fn(),
17	readFile: vi.fn(),
18}));

❌ Keep These Real (Data Contracts)

1// ✅ Real FormData objects
2const form_data = new FormData();
3form_data.append('email', '[email protected]');
4
5// ✅ Real Request/Response objects
6const request = new Request('http://localhost/api/users', {
7	method: 'POST',
8	body: form_data,
9});
10
11// ✅ Real validation functions
12const validation_result = validate_user_input(form_data);
13
14// ✅ Real data transformation utilities
15const formatted_data = format_user_data(raw_input);

Always Use Locators, Never Containers

The Critical vitest-browser-svelte Pattern

NEVER use containers - they don’t have auto-retry and require manual DOM queries:

1// ❌ NEVER use containers - no auto-retry, manual DOM queries
2it('should handle button click - WRONG WAY', async () => {
3	const { container } = render(MyComponent);
4	const button = container.querySelector('[data-testid="submit"]');
5	// This can fail randomly due to timing issues
6});
7
8// ✅ ALWAYS use locators - auto-retry, semantic queries
9it('should handle button click', async () => {
10	await render(MyComponent);
11	const button = page.getByTestId('submit');
12	await button.click(); // Automatic waiting and retrying
13
14	await expect.element(page.getByText('Success')).toBeInTheDocument();
15});

Locator Patterns with Auto-retry

1describe('Locator Best Practices', () => {
2	it('should use semantic queries first', async () => {
3		await render(LoginForm);
4
5		// ✅ Semantic queries (preferred - test accessibility)
6		const email_input = page.getByRole('textbox', { name: 'Email' });
7		const password_input = page.getByLabelText('Password');
8		const submit_button = page.getByRole('button', {
9			name: 'Sign In',
10		});
11
12		await email_input.fill('[email protected]');
13		await password_input.fill('password123');
14		await submit_button.click();
15
16		await expect
17			.element(page.getByText('Welcome back!'))
18			.toBeInTheDocument();
19	});
20
21	it('should handle multiple elements with strict mode', async () => {
22		await render(NavigationMenu);
23
24		// ❌ FAILS: Multiple elements match
25		// page.getByRole('link', { name: 'Home' });
26
27		// ✅ CORRECT: Use .first(), .nth(), .last()
28		const home_link = page
29			.getByRole('link', { name: 'Home' })
30			.first();
31		await home_link.click();
32
33		await expect
34			.element(page.getByHeading('Welcome Home'))
35			.toBeInTheDocument();
36	});
37
38	it('should use test ids when semantic queries are not possible', async () => {
39		await render(ComplexWidget);
40
41		// ✅ Test IDs (when semantic queries aren't possible)
42		const widget = page.getByTestId('complex-widget');
43		await expect.element(widget).toBeInTheDocument();
44
45		// Still prefer semantic queries for interactions
46		const action_button = page.getByRole('button', {
47			name: 'Process Data',
48		});
49		await action_button.click();
50	});
51});

Avoid Testing Implementation Details

Focus on User Value, Not Internal Structure

NEVER test exact implementation details that provide no user value:

1// ❌ BRITTLE ANTI-PATTERN - Tests exact SVG path data
2it('should render check icon - WRONG WAY', () => {
3	const { body } = render(StatusIcon, { status: 'success' });
4
5	// This breaks when icon libraries update, even if visually identical
6	expect(body).toContain(
7		'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
8	);
9});
10
11// ✅ ROBUST PATTERN - Tests semantic meaning and user experience
12it('should indicate success state to users', async () => {
13	await render(StatusIcon, { status: 'success' });
14
15	// Test what users actually see and experience
16	await expect
17		.element(page.getByRole('img', { name: /success/i }))
18		.toBeInTheDocument();
19	await expect
20		.element(page.getByTestId('status-icon'))
21		.toHaveClass('text-success');
22});

Test These ✅

Semantic Classes: CSS classes that control user-visible appearance

1it('should apply correct styling classes', () => {
2	const { body } = render(Button, { variant: 'success' });
3
4	expect(body).toContain('text-success'); // Color indicates success
5	expect(body).toContain('btn-success'); // Semantic button class
6	expect(body).toContain('px-4 py-2'); // Consistent spacing
7});

User-Visible Behavior: What users actually experience

1it('should respond to user interactions', async () => {
2	const click_handler = vi.fn();
3	await render(Button, { onclick: click_handler });
4
5	const button = page.getByRole('button');
6	await button.click();
7
8	expect(click_handler).toHaveBeenCalledOnce();
9	await expect.element(button).toBeFocused();
10});

Don’t Test These ❌

Exact SVG Path Coordinates: Mathematical details users don’t see

1// ❌ Brittle - breaks when icon library updates
2expect(body).toContain(
3	'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
4);

Internal Implementation Details: Library-specific markup

1// ❌ Brittle - breaks when component library updates
2expect(body).toContain('__svelte_component_internal_123');
3expect(body).toContain('data-radix-collection-item');

Svelte 5 Runes Testing

Testing Reactive State with untrack()

1import { flushSync, untrack } from 'svelte';
2
3describe('Reactive State', () => {
4	it('should handle $state and $derived correctly', () => {
5		let count = $state(0);
6		let doubled = $derived(count * 2);
7
8		// ✅ Always use untrack() for $derived values
9		expect(untrack(() => doubled)).toBe(0);
10
11		count = 5;
12		flushSync(); // Still needed for derived state evaluation
13
14		expect(untrack(() => doubled)).toBe(10);
15	});
16
17	it('should test form state lifecycle', () => {
18		const form_state = create_form_state({
19			email: { value: '', validation_rules: { required: true } },
20		});
21
22		// ✅ Test the full lifecycle: valid → validate → invalid → fix → valid
23		expect(untrack(() => form_state.is_form_valid())).toBe(true); // Initially valid
24
25		form_state.validate_all_fields();
26		expect(untrack(() => form_state.is_form_valid())).toBe(false); // Now invalid
27
28		form_state.email.value = '[email protected]';
29		expect(untrack(() => form_state.is_form_valid())).toBe(true); // Valid again
30	});
31});

Accessibility Testing Patterns

Semantic Queries Priority

Always prefer semantic queries that test accessibility:

1describe('Accessibility Best Practices', () => {
2	it('should use semantic queries for better accessibility testing', async () => {
3		await render(ContactForm);
4
5		// ✅ EXCELLENT - Tests accessibility and semantics
6		const name_input = page.getByRole('textbox', {
7			name: 'Full Name',
8		});
9		const email_input = page.getByLabelText('Email address');
10		const submit_button = page.getByRole('button', {
11			name: 'Submit form',
12		});
13
14		await name_input.fill('John Doe');
15		await email_input.fill('[email protected]');
16		await submit_button.click();
17
18		// ✅ GOOD - Tests text content users see
19		await expect
20			.element(page.getByText('Thank you, John!'))
21			.toBeInTheDocument();
22	});
23
24	it('should test ARIA properties and roles', async () => {
25		await render(Modal, { open: true, title: 'Settings' });
26
27		const modal = page.getByRole('dialog');
28		await expect.element(modal).toHaveAttribute('aria-labelledby');
29		await expect.element(modal).toHaveAttribute('aria-modal', 'true');
30
31		const title = page.getByRole('heading', { level: 2 });
32		await expect.element(title).toHaveText('Settings');
33	});
34
35	it('should test keyboard navigation', async () => {
36		await render(TabPanel);
37
38		const first_tab = page.getByRole('tab').first();
39		await first_tab.element().focus();
40
41		// Test arrow key navigation
42		await page.keyboard.press('ArrowRight');
43		const second_tab = page.getByRole('tab').nth(1);
44		await expect.element(second_tab).toBeFocused();
45
46		// Test Enter key activation
47		await page.keyboard.press('Enter');
48		await expect
49			.element(second_tab)
50			.toHaveAttribute('aria-selected', 'true');
51	});
52});

Component Testing Patterns

Props and Event Testing

1describe('Component Props and Events', () => {
2	it('should handle all prop variants systematically', async () => {
3		const variants = ['primary', 'secondary', 'danger'] as const;
4		const sizes = ['sm', 'md', 'lg'] as const;
5
6		for (const variant of variants) {
7			for (const size of sizes) {
8				await render(Button, { variant, size });
9
10				const button = page.getByRole('button');
11				await expect.element(button).toHaveClass(`btn-${variant}`);
12				await expect.element(button).toHaveClass(`btn-${size}`);
13			}
14		}
15	});
16
17	it('should handle multiple event types', async () => {
18		const handlers = {
19			click: vi.fn(),
20			focus: vi.fn(),
21			blur: vi.fn(),
22			keydown: vi.fn(),
23		};
24
25		await render(InteractiveComponent, {
26			onclick: handlers.click,
27			onfocus: handlers.focus,
28			onblur: handlers.blur,
29			onkeydown: handlers.keydown,
30		});
31
32		const element = page.getByRole('button');
33
34		// Test click
35		await element.click();
36		expect(handlers.click).toHaveBeenCalledOnce();
37
38		// Test focus/blur
39		await element.element().focus();
40		expect(handlers.focus).toHaveBeenCalledOnce();
41
42		await element.element().blur();
43		expect(handlers.blur).toHaveBeenCalledOnce();
44
45		// Test keyboard
46		await element.element().focus();
47		await element.press('Enter');
48		expect(handlers.keydown).toHaveBeenCalledWith(
49			expect.objectContaining({ key: 'Enter' }),
50		);
51	});
52});

Mocking Best Practices

Smart Mocking Strategy

1describe('Mocking Patterns', () => {
2	// ✅ Mock utility functions with realistic return values
3	vi.mock('#lib/utils/data-fetcher.js', () => ({
4		fetch_user_data: vi.fn(() =>
5			Promise.resolve({
6				id: '1',
7				name: 'Test User',
8				email: '[email protected]',
9			}),
10		),
11		fetch_todos: vi.fn(() =>
12			Promise.resolve([
13				{ id: '1', title: 'Test Todo', completed: false },
14			]),
15		),
16	}));
17
18	it('should verify mocks are working correctly', async () => {
19		const { fetch_user_data } =
20			await import('#lib/utils/data-fetcher.js');
21
22		expect(fetch_user_data).toBeDefined();
23		expect(vi.isMockFunction(fetch_user_data)).toBe(true);
24
25		const result = await fetch_user_data('123');
26		expect(result).toEqual({
27			id: '1',
28			name: 'Test User',
29			email: '[email protected]',
30		});
31	});
32
33	it('should test component with mocked data', async () => {
34		await render(UserProfile, { user_id: '123' });
35
36		// Wait for async data loading
37		await expect
38			.element(page.getByText('Test User'))
39			.toBeInTheDocument();
40		await expect
41			.element(page.getByText('[email protected]'))
42			.toBeInTheDocument();
43	});
44});

Error Handling and Edge Cases

Robust Error Testing

1describe('Error Handling', () => {
2	it('should handle component errors gracefully', async () => {
3		// Mock console.error to avoid test noise
4		const console_error = vi
5			.spyOn(console, 'error')
6			.mockImplementation(() => {});
7
8		await render(ErrorBoundary, {
9			children: createRawSnippet(() => ({
10				render: () => {
11					throw new Error('Component crashed!');
12				},
13			})),
14		});
15
16		await expect
17			.element(page.getByText('Something went wrong'))
18			.toBeInTheDocument();
19		await expect
20			.element(page.getByRole('button', { name: 'Try again' }))
21			.toBeInTheDocument();
22
23		console_error.mockRestore();
24	});
25
26	it('should handle network failures', async () => {
27		// Mock fetch to simulate network error
28		vi.spyOn(global, 'fetch').mockRejectedValueOnce(
29			new Error('Network error'),
30		);
31
32		await render(DataComponent);
33
34		await expect
35			.element(page.getByText('Failed to load data'))
36			.toBeInTheDocument();
37		await expect
38			.element(page.getByRole('button', { name: 'Retry' }))
39			.toBeInTheDocument();
40	});
41
42	it('should handle empty data states', async () => {
43		await render(TodoList, { todos: [] });
44
45		await expect
46			.element(page.getByText('No todos yet'))
47			.toBeInTheDocument();
48		await expect
49			.element(page.getByText('Add your first todo to get started'))
50			.toBeInTheDocument();
51	});
52});

Performance and Animation Testing

Handle Animations and Timing

1describe('Animation and Performance', () => {
2	it('should handle animated elements', async () => {
3		await render(AnimatedModal, { open: true });
4
5		const modal = page.getByRole('dialog');
6
7		// ✅ Use force: true for elements that may be animating
8		const close_button = page.getByRole('button', { name: 'Close' });
9		await close_button.click({ force: true });
10
11		// Wait for animation to complete
12		await expect.element(modal).not.toBeInTheDocument();
13	});
14
15	it('should test component performance', async () => {
16		const start = performance.now();
17
18		await render(ComplexDashboard, { data: large_dataset });
19
20		// Wait for initial render
21		await expect
22			.element(page.getByTestId('dashboard'))
23			.toBeInTheDocument();
24
25		const render_time = performance.now() - start;
26		expect(render_time).toBeLessThan(1000); // Should render within 1 second
27	});
28});

SSR Testing Patterns

Server-Side Rendering Validation

1import { render } from 'svelte/server';
2
3describe('SSR Testing', () => {
4	it('should render without errors on server', () => {
5		expect(() => {
6			render(ComponentName);
7		}).not.toThrow();
8	});
9
10	it('should render essential content for SEO', () => {
11		const { body, head } = render(HomePage);
12
13		// Test core content
14		expect(body).toContain('<h1>Welcome to Our Site</h1>');
15		expect(body).toContain('href="/about"');
16		expect(body).toContain('main');
17
18		// Test meta information
19		expect(head).toContain('<title>');
20		expect(head).toContain('meta name="description"');
21	});
22
23	it('should handle props correctly in SSR', () => {
24		const { body } = render(UserCard, {
25			user: { name: 'John Doe', email: '[email protected]' },
26		});
27
28		expect(body).toContain('John Doe');
29		expect(body).toContain('[email protected]');
30	});
31});

Quick Reference Checklist

Essential Patterns ✅

  • Use describe and it (not test) for consistency with Vitest docs
  • Use it.skip for planned tests, not strict 100% coverage
  • Always use locators (page.getBy*()) - never containers
  • Always await locator assertions: await expect.element()
  • Use untrack() for Svelte 5 $derived values
  • Use .first(), .nth(), .last() for multiple elements
  • Use force: true for animations: await element.click({ force: true })
  • Prefer semantic queries over test IDs
  • Test user value, not implementation details
  • Use real FormData/Request objects in server tests
  • Share validation logic between client and server
  • Mock external services, keep data contracts real

Common Mistakes ❌

  • Never click SvelteKit form submits - test state directly
  • Don’t ignore strict mode violations - use .first() instead
  • Don’t test SVG paths or internal markup
  • Don’t assume element roles - verify with browser dev tools
  • Don’t write tests for arbitrary coverage metrics
  • Don’t use containers from render() - use page locators instead

Code Style Requirements

  • Use snake_case for variables and functions
  • Use kebab-case for file names
  • Prefer arrow functions where possible
  • Keep interfaces in TitleCase