Component Testing

Overview

This guide covers component testing patterns for Svelte 5 applications using vitest-browser-svelte. For general testing patterns, see Testing Patterns. For setup and configuration, see Getting Started.

Essential Setup Pattern

Every component test file should start with this setup:

1import { describe, expect, it, vi } from 'vitest';
2import { render } from 'vitest-browser-svelte';
3import { page, userEvent } from 'vitest/browser';
4import { createRawSnippet } from 'svelte';
5import { flushSync, untrack } from 'svelte';
6
7// Import your component
8import MyComponent from './my-component.svelte';

Locator Patterns

Basic Locator Usage

1it('should use semantic locators', async () => {
2	await render(MyComponent);
3
4	// ✅ Semantic queries (preferred - test accessibility)
5	const submit_button = page.getByRole('button', { name: 'Submit' });
6	const email_input = page.getByRole('textbox', { name: 'Email' });
7	const email_label = page.getByLabel('Email address');
8	const welcome_text = page.getByText('Welcome');
9
10	// ✅ Test IDs (when semantic queries aren't possible)
11	const complex_widget = page.getByTestId('data-visualization');
12
13	// ✅ Always await assertions
14	await expect.element(submit_button).toBeInTheDocument();
15	await expect.element(email_input).toHaveAttribute('type', 'email');
16});

DOM Manipulation Methods

Important: Vitest browser locators require .element() to access native DOM methods like focus() and blur():

1it('should access DOM methods through .element()', async () => {
2	await render(MyComponent);
3
4	const input = page.getByRole('textbox', { name: 'Email' });
5
6	// ❌ WRONG: focus() doesn't exist on Vitest locators
7	// await input.focus();
8
9	// ✅ CORRECT: Use .element() to access native DOM methods
10	await input.element().focus();
11	await input.element().blur();
12
13	// Note: This is different from Playwright e2e tests where
14	// locators have focus() directly: await locator.focus()
15});

Why the difference?

  • Playwright locators (e2e tests with @playwright/test): Have focus(), blur(), etc. directly available
  • Vitest browser locators (component tests with vitest-browser-svelte): Require .element() to access these methods

This design keeps Vitest locators lightweight while providing access to all native DOM methods when needed.

Handling Multiple Elements (Strict Mode)

vitest-browser-svelte operates in strict mode - if multiple elements match, you must specify which one:

1it('should handle multiple matching elements', async () => {
2	await render(NavigationComponent);
3
4	// ❌ FAILS: Strict mode violation if desktop + mobile nav both exist
5	// page.getByRole('link', { name: 'Home' });
6
7	// ✅ CORRECT: Use .first(), .nth(), or .last()
8	const desktop_home_link = page
9		.getByRole('link', { name: 'Home' })
10		.first();
11	const mobile_home_link = page
12		.getByRole('link', { name: 'Home' })
13		.last();
14	const second_link = page.getByRole('link', { name: 'Home' }).nth(1);
15
16	await expect.element(desktop_home_link).toBeInTheDocument();
17	await expect.element(mobile_home_link).toBeInTheDocument();
18});

Role Confusion Fixes

Common role mistakes and their solutions:

1it('should use correct element roles', async () => {
2	await render(FormComponent);
3
4	// ❌ WRONG: Input role doesn't exist
5	// page.getByRole('input', { name: 'Email' });
6
7	// ✅ CORRECT: Use textbox for input elements
8	const email_input = page.getByRole('textbox', { name: 'Email' });
9
10	// ❌ WRONG: Looking for link when element has role="button"
11	// page.getByRole('link', { name: 'Submit' }); // <a role="button">Submit</a>
12
13	// ✅ CORRECT: Use the actual role attribute
14	const submit_link_button = page.getByRole('button', {
15		name: 'Submit',
16	});
17
18	await expect.element(email_input).toBeInTheDocument();
19	await expect.element(submit_link_button).toBeInTheDocument();
20});

Component Testing Patterns

Button Component Pattern

1describe('Button Component', () => {
2	it('should render with variant styling', async () => {
3		await render(Button, {
4			variant: 'primary',
5			children: 'Click me',
6		});
7
8		const button = page.getByRole('button', { name: 'Click me' });
9		await expect.element(button).toBeInTheDocument();
10		await expect.element(button).toHaveClass('btn-primary');
11	});
12
13	it('should handle click events', async () => {
14		const click_handler = vi.fn();
15		await render(Button, {
16			onclick: click_handler,
17			children: 'Click me',
18		});
19
20		const button = page.getByRole('button', { name: 'Click me' });
21		await button.click();
22
23		expect(click_handler).toHaveBeenCalledOnce();
24	});
25
26	it('should support disabled state', async () => {
27		await render(Button, { disabled: true, children: 'Disabled' });
28
29		const button = page.getByRole('button', { name: 'Disabled' });
30		await expect.element(button).toBeDisabled();
31		await expect.element(button).toHaveClass('btn-disabled');
32	});
33
34	it('should handle animations with force click', async () => {
35		await render(AnimatedButton, { children: 'Animated' });
36
37		const button = page.getByRole('button', { name: 'Animated' });
38		// Use force: true for elements that may be animating
39		await button.click({ force: true });
40
41		await expect
42			.element(page.getByText('Animation complete'))
43			.toBeInTheDocument();
44	});
45});

Input Component Pattern

1describe('Input Component', () => {
2	it('should handle user input', async () => {
3		await render(Input, { type: 'text', label: 'Full Name' });
4
5		const input = page.getByLabelText('Full Name');
6		await input.fill('John Doe');
7
8		await expect.element(input).toHaveValue('John Doe');
9	});
10
11	it('should display validation errors', async () => {
12		await render(Input, {
13			type: 'email',
14			label: 'Email',
15			error: 'Invalid email format',
16		});
17
18		const input = page.getByLabelText('Email');
19		const error_message = page.getByText('Invalid email format');
20
21		await expect.element(error_message).toBeInTheDocument();
22		await expect
23			.element(input)
24			.toHaveAttribute('aria-invalid', 'true');
25		await expect.element(input).toHaveClass('input-error');
26	});
27
28	it('should support different input types', async () => {
29		await render(Input, { type: 'password', label: 'Password' });
30
31		const input = page.getByLabelText('Password');
32		await expect.element(input).toHaveAttribute('type', 'password');
33	});
34});
1describe('Modal Component', () => {
2	it('should handle focus management', async () => {
3		await render(Modal, { open: true, children: 'Modal content' });
4
5		const modal = page.getByRole('dialog');
6		await expect.element(modal).toBeInTheDocument();
7
8		// Test focus trap
9		await userEvent.keyboard('{Tab}');
10		const close_button = page.getByRole('button', { name: 'Close' });
11		await expect.element(close_button).toBeFocused();
12	});
13
14	it('should close on escape key', async () => {
15		const close_handler = vi.fn();
16		await render(Modal, { open: true, onclose: close_handler });
17
18		await userEvent.keyboard('{Escape}');
19		expect(close_handler).toHaveBeenCalledOnce();
20	});
21
22	it('should prevent background scroll when open', async () => {
23		await render(Modal, { open: true });
24
25		const body = page.locator('body');
26		await expect.element(body).toHaveClass('modal-open');
27	});
28});
1describe('Dropdown Component', () => {
2	it('should open and close on click', async () => {
3		const options = [
4			{ value: 'option1', label: 'Option 1' },
5			{ value: 'option2', label: 'Option 2' },
6		];
7		await render(Dropdown, { options, label: 'Choose option' });
8
9		const trigger = page.getByRole('button', {
10			name: 'Choose option',
11		});
12		await trigger.click();
13
14		// Dropdown should be open
15		const option1 = page.getByRole('option', { name: 'Option 1' });
16		await expect.element(option1).toBeInTheDocument();
17
18		// Select an option
19		await option1.click();
20
21		// Dropdown should close and show selected value
22		await expect.element(trigger).toHaveTextContent('Option 1');
23	});
24
25	it('should support keyboard navigation', async () => {
26		const options = [
27			{ value: 'option1', label: 'Option 1' },
28			{ value: 'option2', label: 'Option 2' },
29		];
30		await render(Dropdown, { options, label: 'Choose option' });
31
32		const trigger = page.getByRole('button', {
33			name: 'Choose option',
34		});
35		await trigger.element().focus();
36		await userEvent.keyboard('{Enter}');
37
38		// Navigate with arrow keys
39		await userEvent.keyboard('{ArrowDown}');
40		await userEvent.keyboard('{Enter}');
41
42		await expect.element(trigger).toHaveTextContent('Option 1');
43	});
44});

Mocking Patterns

Component Mocking Pattern

1// Mock child components to isolate testing
2vi.mock('./child-component.svelte', () => ({
3	default: vi.fn().mockImplementation(() => ({
4		$$: {},
5		$set: vi.fn(),
6		$destroy: vi.fn(),
7		$on: vi.fn(),
8	})),
9}));
10
11describe('Parent Component', () => {
12	it('should render with mocked child', async () => {
13		await render(ParentComponent);
14
15		// Test parent functionality without child complexity
16		const parent_element = page.getByTestId('parent');
17		await expect.element(parent_element).toBeInTheDocument();
18	});
19});

Utility Function Mocking Pattern

1// Mock utility functions with realistic return values
2vi.mock('#lib/utils/api.js', () => ({
3	fetch_user_data: vi.fn(() =>
4		Promise.resolve({
5			id: 1,
6			name: 'John Doe',
7			email: '[email protected]',
8		}),
9	),
10	validate_email: vi.fn((email: string) => email.includes('@')),
11}));
12
13describe('User Profile Component', () => {
14	it('should load user data on mount', async () => {
15		await render(UserProfile, { user_id: 1 });
16
17		await expect
18			.element(page.getByText('John Doe'))
19			.toBeInTheDocument();
20		await expect
21			.element(page.getByText('[email protected]'))
22			.toBeInTheDocument();
23	});
24});

Store Mocking Pattern

1// Mock Svelte stores
2vi.mock('#lib/stores/user.js', () => ({
3	user_store: {
4		subscribe: vi.fn((callback) => {
5			callback({ id: 1, name: 'Test User' });
6			return () => {}; // Unsubscribe function
7		}),
8		set: vi.fn(),
9		update: vi.fn(),
10	},
11}));
12
13describe('User Dashboard', () => {
14	it('should display user information from store', async () => {
15		await render(UserDashboard);
16
17		await expect
18			.element(page.getByText('Test User'))
19			.toBeInTheDocument();
20	});
21});

Quick Reference

Essential Patterns Checklist

  • ✅ Use page.getBy*() locators - never containers
  • ✅ Always await locator assertions: await expect.element()
  • ✅ Use .first(), .nth(), .last() for multiple elements
  • ✅ Use force: true for animations: await element.click({ force: true })
  • ✅ Use snake_case for variables/functions, kebab-case for files
  • ✅ Handle role confusion: textbox not input, check actual role attributes

Common Fixes

  • “strict mode violation”: Use .first(), .nth(), .last()
  • Role confusion: Links with role="button" are buttons, use getByRole('button')
  • Input elements: Use getByRole('textbox'), not getByRole('input')
  • Animation issues: Use force: true for click events

Anti-Patterns to Avoid

  • ❌ Never use containers: const { container } = render()
  • ❌ Don’t ignore strict mode violations
  • ❌ Don’t assume element roles - verify with browser dev tools