Svelte Component Testing with Vitest and Testing Library (2026)

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 jsdom

Create 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 unchecked

When 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.

Read more