Unit Testing Svelte Components with Vitest: A Complete Guide
Vitest is the natural testing companion for Svelte projects. It shares Vite's transform pipeline, which means Svelte components compile without extra config. Combined with @testing-library/svelte, you get a fast, ergonomic unit testing setup that tests components the way users interact with them.
Setup
npm create svelte@latest my-app
cd my-app
npm install
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdomConfigure Vitest in vite.config.ts:
import { defineConfig } from 'vite';
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 src/test-setup.ts:
import '@testing-library/jest-dom';Basic Component Testing
Given a simple counter component:
<!-- Counter.svelte -->
<script lang="ts">
let count = $state(0);
function increment() {
count++;
}
function decrement() {
count--;
}
</script>
<button on:click={decrement}>-</button>
<span data-testid="count">{count}</span>
<button on:click={increment}>+</button>Test it:
import { render, fireEvent, screen } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('renders initial count of 0', () => {
render(Counter);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('increments count on + click', async () => {
render(Counter);
await fireEvent.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('decrements count on - click', async () => {
render(Counter);
await fireEvent.click(screen.getByRole('button', { name: '-' }));
expect(screen.getByTestId('count')).toHaveTextContent('-1');
});
});Testing Props
<!-- Greeting.svelte -->
<script lang="ts">
export let name: string;
export let greeting = 'Hello';
</script>
<h1>{greeting}, {name}!</h1>import { render, screen } from '@testing-library/svelte';
import Greeting from './Greeting.svelte';
describe('Greeting', () => {
it('renders with required prop', () => {
render(Greeting, { props: { name: 'Alice' } });
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});
it('renders with custom greeting', () => {
render(Greeting, { props: { name: 'Bob', greeting: 'Hi' } });
expect(screen.getByRole('heading')).toHaveTextContent('Hi, Bob!');
});
});Testing Events
<!-- SearchInput.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let placeholder = 'Search...';
const dispatch = createEventDispatcher<{ search: string }>();
let value = '';
function handleSubmit() {
dispatch('search', value);
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input bind:value {placeholder} />
<button type="submit">Search</button>
</form>import { render, fireEvent, screen } from '@testing-library/svelte';
import { vi } from 'vitest';
import SearchInput from './SearchInput.svelte';
describe('SearchInput', () => {
it('dispatches search event with input value', async () => {
const { component } = render(SearchInput);
const searchHandler = vi.fn();
component.$on('search', searchHandler);
await fireEvent.input(screen.getByRole('textbox'), {
target: { value: 'svelte testing' },
});
await fireEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(searchHandler).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'svelte testing' })
);
});
});Testing Slots
<!-- Card.svelte -->
<div class="card">
<header>
<slot name="header">Default header</slot>
</header>
<main>
<slot />
</main>
</div>import { render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
import Card from './Card.svelte';
// @testing-library/svelte supports slots via a wrapper component approach
import CardWithSlots from './CardWithSlots.test.svelte';
describe('Card slots', () => {
it('renders default slot content', () => {
render(CardWithSlots);
expect(screen.getByText('Main content')).toBeInTheDocument();
expect(screen.getByText('Custom header')).toBeInTheDocument();
});
});CardWithSlots.test.svelte:
<script>
import Card from './Card.svelte';
</script>
<Card>
<svelte:fragment slot="header">Custom header</svelte:fragment>
Main content
</Card>Testing Reactive Declarations
<!-- PriceCalculator.svelte -->
<script lang="ts">
export let price: number;
export let quantity: number;
$: total = price * quantity;
$: discount = total > 100 ? total * 0.1 : 0;
$: finalPrice = total - discount;
</script>
<p data-testid="total">{total}</p>
<p data-testid="discount">{discount}</p>
<p data-testid="final">{finalPrice}</p>import { render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
import PriceCalculator from './PriceCalculator.svelte';
describe('PriceCalculator', () => {
it('calculates total correctly', () => {
render(PriceCalculator, { props: { price: 10, quantity: 5 } });
expect(screen.getByTestId('total')).toHaveTextContent('50');
expect(screen.getByTestId('discount')).toHaveTextContent('0');
expect(screen.getByTestId('final')).toHaveTextContent('50');
});
it('applies discount when total exceeds 100', async () => {
const { component } = render(PriceCalculator, {
props: { price: 20, quantity: 6 },
});
// total = 120, discount = 12, final = 108
expect(screen.getByTestId('total')).toHaveTextContent('120');
expect(screen.getByTestId('discount')).toHaveTextContent('12');
expect(screen.getByTestId('final')).toHaveTextContent('108');
});
it('updates when props change', async () => {
const { component } = render(PriceCalculator, {
props: { price: 5, quantity: 10 },
});
await component.$set({ price: 25 });
await tick();
expect(screen.getByTestId('total')).toHaveTextContent('250');
});
});Async Components and Await Blocks
<!-- UserProfile.svelte -->
<script lang="ts">
export let userId: string;
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
const userPromise = fetchUser(userId);
</script>
{#await userPromise}
<p>Loading...</p>
{:then user}
<h2>{user.name}</h2>
<p>{user.email}</p>
{:catch error}
<p role="alert">{error.message}</p>
{/await}import { render, screen, waitFor } from '@testing-library/svelte';
import { vi, beforeEach } from 'vitest';
import UserProfile from './UserProfile.svelte';
describe('UserProfile', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('shows loading state initially', () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ name: 'Alice', email: 'alice@example.com' }),
} as Response);
render(UserProfile, { props: { userId: '1' } });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders user data after fetch', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ name: 'Alice', email: 'alice@example.com' }),
} as Response);
render(UserProfile, { props: { userId: '1' } });
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('Alice');
});
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('shows error state on failed fetch', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
} as Response);
render(UserProfile, { props: { userId: '999' } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('User not found');
});
});
});Mocking Svelte Stores in Tests
import { writable } from 'svelte/store';
import { vi } from 'vitest';
// Mock a store module
vi.mock('./stores/auth', () => ({
user: writable({ id: '1', name: 'Test User', role: 'admin' }),
isAuthenticated: writable(true),
}));Running Tests
# Run all tests
npx vitest
# Watch mode
npx vitest --watch
# Coverage
npx vitest --coverage
# UI mode
npx vitest --uiKey Patterns
- Use
renderfrom@testing-library/svelte— it mounts the component and returns queries - Prefer
screenqueries over destructuredgetBy*— they work without closure issues - Use
await tick()after prop changes with$setto let Svelte re-render - Fire events with
fireEventoruserEventfrom@testing-library/user-eventfor more realistic interactions - Test event dispatching via
component.$on()before firing events - Use
waitForfor async operations in{#await}blocks - Keep slot tests in separate
.test.sveltewrapper files
Svelte components are pure functions of their props and state — this makes unit testing straightforward. Test behavior, not implementation: assert what the user sees, not internal reactive variables.