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): Havefocus(),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});Modal Component Pattern
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});Dropdown/Select Component Pattern
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: truefor animations:await element.click({ force: true }) - ✅ Use snake_case for variables/functions, kebab-case for files
- ✅ Handle role confusion:
textboxnotinput, check actualroleattributes
Common Fixes
- “strict mode violation”: Use
.first(),.nth(),.last() - Role confusion: Links with
role="button"are buttons, usegetByRole('button') - Input elements: Use
getByRole('textbox'), notgetByRole('input') - Animation issues: Use
force: truefor 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