Svelte Component Testing with Vitest and Testing Library (2026)
Svelte components have logic in them. Props control rendering, user interactions trigger state changes, events propagate to parent components. Testing that logic prevents regressions and documents expected behavior. This guide covers Svelte component testing with Vitest and @testing-library/svelte — the combination used by most production SvelteKit projects in 2026.
Setup
Install the testing packages:
npm install -D vitest @testing-library/svelte @testing-library/jest-dom \
@sveltejs/vite-plugin-svelte jsdomCreate vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
});Create the setup file:
// src/test-setup.ts
import '@testing-library/jest-dom';This setup enables all the toBeInTheDocument(), toHaveValue(), and toBeVisible() matchers that make Testing Library assertions readable.
Testing Basic Rendering
Start with a component that renders conditionally based on props.
<!-- src/lib/StatusBadge.svelte -->
<script lang="ts">
export let status: 'active' | 'inactive' | 'pending';
export let label: string = status;
</script>
<span class="badge badge--{status}" aria-label="Status: {label}">
{label}
</span>// src/lib/StatusBadge.test.ts
import { render, screen } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import StatusBadge from './StatusBadge.svelte';
describe('StatusBadge', () => {
it('renders with the provided status label', () => {
render(StatusBadge, { props: { status: 'active' } });
expect(screen.getByText('active')).toBeInTheDocument();
});
it('uses custom label when provided', () => {
render(StatusBadge, { props: { status: 'active', label: 'Online' } });
expect(screen.getByText('Online')).toBeInTheDocument();
});
it('sets the correct aria-label', () => {
render(StatusBadge, { props: { status: 'pending', label: 'Waiting' } });
expect(screen.getByLabelText('Status: Waiting')).toBeInTheDocument();
});
it('applies the correct CSS class for inactive status', () => {
render(StatusBadge, { props: { status: 'inactive' } });
expect(screen.getByText('inactive')).toHaveClass('badge--inactive');
});
});Testing User Interactions
Components that respond to user input need interaction tests.
<!-- src/lib/ToggleSwitch.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let checked = false;
export let label: string;
const dispatch = createEventDispatcher<{ change: { checked: boolean } }>();
function toggle() {
checked = !checked;
dispatch('change', { checked });
}
</script>
<label class="toggle">
<input
type="checkbox"
{checked}
on:change={toggle}
aria-label={label}
/>
<span class="toggle__track"></span>
</label>// src/lib/ToggleSwitch.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import ToggleSwitch from './ToggleSwitch.svelte';
describe('ToggleSwitch', () => {
it('renders unchecked by default', () => {
render(ToggleSwitch, { props: { label: 'Enable notifications' } });
const input = screen.getByLabelText('Enable notifications');
expect(input).not.toBeChecked();
});
it('renders checked when prop is true', () => {
render(ToggleSwitch, { props: { label: 'Enable notifications', checked: true } });
expect(screen.getByLabelText('Enable notifications')).toBeChecked();
});
it('toggles when clicked', async () => {
render(ToggleSwitch, { props: { label: 'Enable notifications' } });
const input = screen.getByLabelText('Enable notifications');
await fireEvent.click(input);
expect(input).toBeChecked();
});
it('dispatches change event with new checked state', async () => {
const { component } = render(ToggleSwitch, {
props: { label: 'Enable notifications' },
});
const changeHandler = vi.fn();
component.$on('change', changeHandler);
await fireEvent.click(screen.getByLabelText('Enable notifications'));
expect(changeHandler).toHaveBeenCalledWith(
expect.objectContaining({ detail: { checked: true } })
);
});
it('toggles back to unchecked on second click', async () => {
render(ToggleSwitch, { props: { label: 'Enable notifications', checked: true } });
const input = screen.getByLabelText('Enable notifications');
await fireEvent.click(input);
expect(input).not.toBeChecked();
});
});Testing Async State
Components that fetch data or handle async operations need special attention.
<!-- src/lib/UserProfile.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
export let userId: string;
let user: { name: string; email: string } | null = null;
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to load user');
user = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading = false;
}
});
</script>
{#if loading}
<div aria-busy="true">Loading...</div>
{:else if error}
<div role="alert">{error}</div>
{:else if user}
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
{/if}// src/lib/UserProfile.test.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import UserProfile from './UserProfile.svelte';
describe('UserProfile', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows loading state initially', () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ name: 'Ada Lovelace', email: 'ada@example.com' }),
} as Response);
render(UserProfile, { props: { userId: '1' } });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('displays user data after loading', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ name: 'Ada Lovelace', email: 'ada@example.com' }),
} as Response);
render(UserProfile, { props: { userId: '1' } });
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Ada Lovelace' })).toBeInTheDocument();
});
expect(screen.getByText('ada@example.com')).toBeInTheDocument();
});
it('shows error message on fetch failure', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
} as Response);
render(UserProfile, { props: { userId: '1' } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user');
});
});
it('calls the correct API endpoint', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ name: 'Test', email: 'test@test.com' }),
} as Response);
render(UserProfile, { props: { userId: 'abc-123' } });
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/users/abc-123');
});
});
});Testing Svelte 5 Runes
Svelte 5 introduces runes — $state, $derived, $effect. Components using runes work identically with Testing Library.
<!-- src/lib/Cart.svelte (Svelte 5) -->
<script lang="ts">
let items = $state<Array<{ name: string; price: number }>>([]);
let total = $derived(items.reduce((sum, item) => sum + item.price, 0));
let count = $derived(items.length);
function addItem(name: string, price: number) {
items = [...items, { name, price }];
}
function removeItem(index: number) {
items = items.filter((_, i) => i !== index);
}
</script>
<div>
<p data-testid="count">Items: {count}</p>
<p data-testid="total">Total: ${total.toFixed(2)}</p>
{#each items as item, i}
<div>
<span>{item.name} — ${item.price.toFixed(2)}</span>
<button on:click={() => removeItem(i)} aria-label="Remove {item.name}">Remove</button>
</div>
{/each}
<button on:click={() => addItem('Widget', 9.99)}>Add Widget</button>
</div>// src/lib/Cart.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Cart from './Cart.svelte';
describe('Cart (Svelte 5 runes)', () => {
it('starts with zero items and total', () => {
render(Cart);
expect(screen.getByTestId('count')).toHaveTextContent('Items: 0');
expect(screen.getByTestId('total')).toHaveTextContent('Total: $0.00');
});
it('updates count and total when item is added', async () => {
render(Cart);
await fireEvent.click(screen.getByText('Add Widget'));
expect(screen.getByTestId('count')).toHaveTextContent('Items: 1');
expect(screen.getByTestId('total')).toHaveTextContent('Total: $9.99');
});
it('removes item and updates total', async () => {
render(Cart);
await fireEvent.click(screen.getByText('Add Widget'));
await fireEvent.click(screen.getByLabelText('Remove Widget'));
expect(screen.getByTestId('count')).toHaveTextContent('Items: 0');
expect(screen.getByTestId('total')).toHaveTextContent('Total: $0.00');
});
});Testing Accessibility
Testing Library queries by ARIA role by default, which forces you to write accessible markup. You can also assert accessibility properties explicitly.
it('form submit button is accessible', () => {
render(LoginForm);
const submitBtn = screen.getByRole('button', { name: /sign in/i });
expect(submitBtn).toBeEnabled();
expect(submitBtn).toHaveAccessibleName('Sign in');
});
it('error messages are announced to screen readers', async () => {
render(LoginForm);
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
const errorMessage = screen.getByRole('alert');
expect(errorMessage).toHaveTextContent(/email is required/i);
});
it('input is associated with its label', () => {
render(LoginForm);
// getByLabelText fails if the input has no associated label
expect(screen.getByLabelText('Email address')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});If getByLabelText throws, your form has missing htmlFor/id associations or missing aria-label attributes. The test failure is the accessibility bug report.
What Component Tests Don't Cover
Component tests prove your component logic is correct. They run in jsdom, not a real browser, which means:
- CSS rendering and visual layout aren't tested
- Real keyboard navigation and focus management require a real browser
- Animation and transition behavior isn't verified
- The component only works in isolation — integration with the real app might fail
You need E2E tests in a real browser and monitoring against your deployed app to fill those gaps.
Production Coverage with HelpMeTest
HelpMeTest tests your live app on a schedule. Write plain-English tests:
Go to https://myapp.com/dashboard
Click the toggle switch labeled "Email notifications"
Verify the toggle is now checked
Click the toggle again
Verify the toggle is now uncheckedWhen a Svelte component breaks in production — a state update doesn't propagate, an event handler stops firing, a Svelte version upgrade breaks component behavior — HelpMeTest alerts you before users report it.
Free tier: 10 tests, unlimited health checks.
Pro: $100/month — unlimited tests, parallel execution, 24/7 monitoring.
Start free at helpmetest.com — no credit card required.