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
.skipas 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 time4. 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
describeandit(nottest) for consistency with Vitest docs - Use
it.skipfor 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$derivedvalues - Use
.first(),.nth(),.last()for multiple elements - Use
force: truefor animations:await element.click({ force: true }) - Prefer semantic queries over test IDs
- Test user value, not implementation details
- Use real
FormData/Requestobjects 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_casefor variables and functions - Use
kebab-casefor file names - Prefer arrow functions where possible
- Keep interfaces in TitleCase