Unit Testing with Vitest
Vitest is the default test runner for SvelteKit projects. Combined with @testing-library/svelte, it lets you render Svelte components in a simulated DOM, interact with them, and assert on the results. The testing pattern follows arrange-act-assert: render the component, interact with it, then check that the DOM reflects the expected state.
Setting Up Vitest
SvelteKit projects created with npx sv create include Vitest configuration. If you need to set it up manually:
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
setupFiles: ['./src/tests/setup.ts']
}
});
// src/tests/setup.ts
import '@testing-library/jest-dom/vitest';
This setup gives you DOM assertions like toBeInTheDocument(), toHaveTextContent(), and toBeVisible().
Rendering Components
Use render from @testing-library/svelte to mount a component in the test DOM:
<!-- src/lib/components/Greeting.svelte -->
<script lang="ts">
let { name }: { name: string } = $props();
</script>
<h1>Hello, {name}</h1>
// src/lib/components/Greeting.test.ts
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Greeting from './Greeting.svelte';
test('renders greeting with name', () => {
// Arrange
render(Greeting, { props: { name: 'Alice' } });
// Assert
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice');
});
The screen object provides queries to find elements by role, text, label, placeholder, and other accessible attributes. Prefer getByRole and getByLabelText over getByTestId because they reflect how users actually find elements.
Testing User Interactions
Use fireEvent or the @testing-library/user-event library to simulate interactions:
<!-- src/lib/components/Counter.svelte -->
<script lang="ts">
let count = $state(0);
</script>
<p>Count: {count}</p>
<button onclick={() => count++}>Increment</button>
<button onclick={() => count--}>Decrement</button>
// src/lib/components/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';
test('increments count on button click', async () => {
// Arrange
render(Counter);
// Act
const incrementButton = screen.getByText('Increment');
await fireEvent.click(incrementButton);
await fireEvent.click(incrementButton);
// Assert
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
test('decrements count on button click', async () => {
render(Counter);
await fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByText('Count: -1')).toBeInTheDocument();
});
fireEvent is async because Svelte needs to update the DOM after state changes. Always await the event.
Testing Reactivity
When state changes, the DOM should update. Test this by interacting with the component and checking the result:
<!-- src/lib/components/TodoList.svelte -->
<script lang="ts">
let items = $state<string[]>([]);
let newItem = $state('');
function addItem() {
if (newItem.trim()) {
items.push(newItem.trim());
newItem = '';
}
}
</script>
<form onsubmit={(e) => { e.preventDefault(); addItem(); }}>
<input
type="text"
bind:value={newItem}
placeholder="Add a todo"
aria-label="New todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>
// src/lib/components/TodoList.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import TodoList from './TodoList.svelte';
test('adds items to the list', async () => {
render(TodoList);
const input = screen.getByLabelText('New todo');
const addButton = screen.getByText('Add');
// Add first item
await fireEvent.input(input, { target: { value: 'Buy groceries' } });
await fireEvent.click(addButton);
// Add second item
await fireEvent.input(input, { target: { value: 'Walk the dog' } });
await fireEvent.click(addButton);
// Assert both items appear
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText('Walk the dog')).toBeInTheDocument();
// Assert the list has two items
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(2);
});
test('clears input after adding', async () => {
render(TodoList);
const input = screen.getByLabelText('New todo') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'Test item' } });
await fireEvent.click(screen.getByText('Add'));
expect(input.value).toBe('');
});
test('does not add empty items', async () => {
render(TodoList);
await fireEvent.input(screen.getByLabelText('New todo'), {
target: { value: ' ' }
});
await fireEvent.click(screen.getByText('Add'));
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
});
Testing Conditional Rendering
Components that show or hide content based on state:
<!-- src/lib/components/Alert.svelte -->
<script lang="ts">
let { type = 'info', message, dismissible = false }:
{ type?: 'info' | 'warning' | 'error'; message: string; dismissible?: boolean } = $props();
let visible = $state(true);
</script>
{#if visible}
<div class="alert alert-{type}" role="alert">
<p>{message}</p>
{#if dismissible}
<button onclick={() => visible = false} aria-label="Dismiss">X</button>
{/if}
</div>
{/if}
// src/lib/components/Alert.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Alert from './Alert.svelte';
test('displays the alert message', () => {
render(Alert, { props: { message: 'Something happened' } });
expect(screen.getByRole('alert')).toHaveTextContent('Something happened');
});
test('dismissible alert can be closed', async () => {
render(Alert, {
props: { message: 'Close me', dismissible: true }
});
expect(screen.getByRole('alert')).toBeInTheDocument();
await fireEvent.click(screen.getByLabelText('Dismiss'));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('non-dismissible alert has no close button', () => {
render(Alert, {
props: { message: 'Stay here', dismissible: false }
});
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
});
Mocking Fetch & Modules
Use Vitest's mocking capabilities to isolate components from external dependencies:
// src/lib/components/UserProfile.test.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import UserProfile from './UserProfile.svelte';
// Mock the fetch function
global.fetch = vi.fn();
test('displays user data after fetching', async () => {
const mockUser = { name: 'Alice', email: 'alice@example.com' };
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify(mockUser), {
headers: { 'Content-Type': 'application/json' }
})
);
render(UserProfile, { props: { userId: '123' } });
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
});
Mocking modules:
// src/lib/components/Dashboard.test.ts
import { render, screen } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
// Mock an imported module
vi.mock('$lib/api', () => ({
getStats: vi.fn().mockResolvedValue({
users: 100,
revenue: 50000
})
}));
import Dashboard from './Dashboard.svelte';
test('displays stats from API', async () => {
render(Dashboard);
await screen.findByText('100');
expect(screen.getByText('$50,000')).toBeInTheDocument();
});
Testing Utility Functions
Not everything needs component rendering. Pure functions should be tested directly:
// src/lib/utils/format.ts
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
// src/lib/utils/format.test.ts
import { expect, test, describe } from 'vitest';
import { formatCurrency, slugify } from './format';
describe('formatCurrency', () => {
test('formats whole numbers', () => {
expect(formatCurrency(1000)).toBe('$1,000.00');
});
test('formats decimals', () => {
expect(formatCurrency(29.99)).toBe('$29.99');
});
test('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
});
describe('slugify', () => {
test('converts spaces to hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('removes special characters', () => {
expect(slugify('What is this?!')).toBe('what-is-this');
});
test('trims leading and trailing hyphens', () => {
expect(slugify(' hello ')).toBe('hello');
});
});
Running Tests
# Run all tests
npx vitest
# Run tests once (CI mode)
npx vitest run
# Run specific test file
npx vitest src/lib/components/Counter.test.ts
# Run tests matching a pattern
npx vitest --reporter=verbose Counter
# Watch mode (default)
npx vitest --watch
Common Pitfalls
- Not awaiting fireEvent calls. Svelte updates the DOM asynchronously after state changes. Without
await, your assertions run before the DOM updates and see stale content. - Using
getBywhen the element might not exist.getByTextthrows if the element is not found. UsequeryByTextwhen you want to assert absence, as it returnsnullinstead of throwing. - Testing implementation details. Do not assert on CSS classes, internal state variables, or component internals. Test what the user sees: text content, visibility, and behavior after interaction.
- Forgetting to clean up mocks. Mocks persist across tests in the same file. Use
vi.restoreAllMocks()inafterEachorvi.clearAllMocks()to prevent test pollution. - Over-relying on snapshot tests. Snapshots are brittle. A minor CSS change or text update breaks them. Use explicit assertions on specific content instead.
Key Takeaways
- Use
renderto mount components,screento query the DOM, andfireEventto simulate interactions. - Follow arrange-act-assert: render the component, interact with it, then check the result.
- Always
awaitfireEventcalls so Svelte has time to update the DOM. - Use
getByRoleandgetByLabelTextovergetByTestIdfor queries that reflect real user behavior. - Mock external dependencies with
vi.fn()andvi.mock()to isolate components. - Test utility functions directly without component rendering. Not everything needs the DOM.
- Run
npx vitest runin CI for a single pass,npx vitestlocally for watch mode.