Unit Testing Svelte Components with Vitest: A Complete Guide

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 jsdom

Configure 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 --ui

Key Patterns

  • Use render from @testing-library/svelte — it mounts the component and returns queries
  • Prefer screen queries over destructured getBy* — they work without closure issues
  • Use await tick() after prop changes with $set to let Svelte re-render
  • Fire events with fireEvent or userEvent from @testing-library/user-event for more realistic interactions
  • Test event dispatching via component.$on() before firing events
  • Use waitFor for async operations in {#await} blocks
  • Keep slot tests in separate .test.svelte wrapper 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.

Read more

Start now free