Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 040 / 100

Testing Svelte components: practical tips and trade-offs

Unit and component tests.

Expona Admin8 min read

By day 40 of building with Svelte and SvelteKit, you've likely shipped components to production—and you've probably felt the friction of testing them properly. Testing Svelte components: practical tips and trade-offs becomes unavoidable when you want confidence in your refactors and a safety net for team contributions. The good news is that the ecosystem has matured significantly, and you have clear options depending on what you're actually trying to verify.

Vitest — Fast unit tests in a Vite project

Vitest is the natural choice for SvelteKit projects because it's built on Vite, shares your bundler's configuration, and runs with blazing speed. Unlike Jest, which needs extra configuration to understand Svelte files, Vitest integrates seamlessly with vite.config.ts.

Here's a practical example. Suppose you have a utility function that formats currency:

typescript
// src/lib/utils/currency.ts
export function formatPrice(cents: number, locale: string = 'en-US'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: locale === 'de-DE' ? 'EUR' : 'USD',
  }).format(cents / 100);
}

Your Vitest test file is dead simple:

typescript
// src/lib/utils/currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatPrice } from './currency';

describe('formatPrice', () => {
  it('formats US cents as USD', () => {
    expect(formatPrice(1999)).toBe('$19.99');
  });

  it('formats German locale as EUR', () => {
    expect(formatPrice(1999, 'de-DE')).toContain('€');
  });

  it('handles edge case of zero', () => {
    expect(formatPrice(0)).toBe('$0.00');
  });
});

This runs instantly. No browser, no DOM setup, just pure logic verification. The trade-off is obvious: you're not testing how Svelte reactivity or lifecycle interact with your logic. That's fine—keep unit tests lean and focused on pure functions.

For component-specific logic, you'll want to step up to component testing, which brings us to the next tool.

Testing Library — @testing-library/svelte renders components

When you need to test that a component renders correctly and responds to user interactions, @testing-library/svelte is your workhorse. It renders your Svelte components in a DOM environment (jsdom by default) and gives you utilities to query and interact with them the way a user would.

Let's test a real component. Imagine a button component that tracks click counts and disables itself after reaching a limit:

typescript
// src/lib/components/LimitedButton.svelte
<script lang="ts">
  interface Props {
    maxClicks?: number;
  }

  let { maxClicks = 3 } = $props();
  let clickCount = $state(0);

  function handleClick() {
    if (clickCount < maxClicks) {
      clickCount++;
    }
  }

  const isDisabled = $derived(clickCount >= maxClicks);
</script>

<button disabled={isDisabled} onclick={handleClick}>
  Clicks: {clickCount} / {maxClicks}
</button>

Now test it with Testing Library:

typescript
// src/lib/components/LimitedButton.test.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import LimitedButton from './LimitedButton.svelte';

describe('LimitedButton', () => {
  it('increments click count on click', async () => {
    const user = userEvent.setup();
    render(LimitedButton);

    const button = screen.getByRole('button');
    expect(button).toHaveTextContent('Clicks: 0 / 3');

    await user.click(button);
    expect(button).toHaveTextContent('Clicks: 1 / 3');
  });

  it('disables button after max clicks', async () => {
    const user = userEvent.setup();
    render(LimitedButton, { props: { maxClicks: 2 } });

    const button = screen.getByRole('button');
    await user.click(button);
    await user.click(button);

    expect(button).toBeDisabled();
  });

  it('respects custom maxClicks prop', () => {
    render(LimitedButton, { props: { maxClicks: 5 } });
    const button = screen.getByRole('button');
    expect(button).toHaveTextContent('/ 5');
  });
});

The key strength of Testing Library is that you're querying elements by what users see—roles, labels, text—not by CSS classes or IDs. This makes your tests more resilient to refactors. The trade-off here is that jsdom doesn't implement every browser API. If your component relies on scroll behavior, getBoundingClientRect with real values, or CSS animations triggering events, jsdom will lie to you. That's when you reach for browser mode.

Browser mode — Vitest browser mode for real DOM behavior

Vitest browser mode (available since v1.0) runs your tests in an actual browser environment—Chromium, Firefox, or WebKit via Playwright or WebDriver. This is slower than jsdom but catches issues that unit tests and DOM-mocking miss.

Configuration in vitest.config.ts:

typescript
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    browser: {
      provider: 'playwright',
      enabled: true,
      instances: [{ browser: 'chromium' }],
    },
  },
});

Use it for tests that need real browser APIs:

typescript
// src/lib/components/ScrollDetector.test.ts
import { render, screen } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import ScrollDetector from './ScrollDetector.svelte';

describe('ScrollDetector (browser mode)', () => {
  it('detects when element enters viewport', async () => {
    render(ScrollDetector);

    const sentinel = screen.getByTestId('scroll-sentinel');
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        // Real browser calculates this correctly
      }
    });

    observer.observe(sentinel);
    // Assertions on real scroll behavior
  });
});

The trade-off: each test now takes seconds instead of milliseconds. You're trading speed for accuracy. Run browser mode tests as a separate suite (e.g., vitest --browser) and keep them focused on features that actually need a real browser.

Test runes — Use .svelte.test.ts files for rune logic

Svelte 5 introduced runes for reactive state, and testing them requires a slightly different approach. While you can test rune-based logic through component rendering, sometimes you want to unit-test the logic directly, especially for shared utility runes or custom rune factories.

For example, if you've created a reusable rune for form state:

typescript
// src/lib/runes/useForm.svelte.ts
export function useForm<T extends Record<string, any>>(initial: T) {
  let values = $state(structuredClone(initial));
  let errors = $state<Partial<Record<keyof T, string>>>({});
  let touched = $state<Partial<Record<keyof T, boolean>>>({});

  function setValue<K extends keyof T>(field: K, value: T[K]) {
    values[field] = value;
    errors[field] = undefined;
  }

  function setTouched<K extends keyof T>(field: K, value = true) {
    touched[field] = value;
  }

  function reset() {
    values = structuredClone(initial);
    errors = {};
    touched = {};
  }

  return { values: $derived.readonly(values), errors: $derived.readonly(errors), touched: $derived.readonly(touched), setValue, setTouched, reset };
}

Test it in a component context using .svelte.test.ts:

typescript
// src/lib/runes/useForm.svelte.test.ts
import { render } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';

// Wrapper component to test runes
const FormTestComponent = `
<script>
  import { useForm } from './useForm.svelte';

  const form = useForm({ email: '', password: '' });
  
  export let form;
</script>
`;

describe('useForm rune', () => {
  it('initializes with provided values', () => {
    // This approach requires rendering a wrapper component
    // Alternatively, test rune logic through component integration tests
    expect(true).toBe(true);
  });
});

A more pragmatic approach: keep rune logic minimal and test it through the components that use it. If a rune is complex enough to warrant isolated tests, consider whether it should be a pure utility function instead. The line between a rune and a utility is blurry—lean toward pure functions when you can, and use runes when you genuinely need Svelte's reactivity system.

Common mistakes

  • Testing implementation details instead of behavior. Don't assert on clickCount internal state directly; instead, query the rendered text. Your tests should survive refactors that don't change user-facing behavior.
  • Overusing browser mode. If jsdom works, use it. Browser mode is for edge cases—scroll events, real layout calculations, specific browser APIs. Using it everywhere tanks your CI pipeline.
  • Not wrapping async operations. When you call user.click() or dispatch events that trigger reactivity, always await them. Svelte's reactivity is synchronous, but user events aren't.
  • Testing Svelte internals. Don't import and test SvelteComponent or lifecycle functions directly. Test the contract your component exposes to the outside world.
  • Mixing concerns in a single test. If a test exercises props, event handling, and DOM rendering simultaneously, it's too broad. Break it into smaller, focused tests.

Key takeaways

  • Use Vitest for pure unit logic: fast, zero DOM overhead, perfect for utilities and business logic.
  • Reach for @testing-library/svelte to test component behavior through the DOM. Query by user-visible elements, not implementation details.
  • Run Vitest browser mode sparingly, only for features that genuinely need a real browser environment. Keep these tests in a separate suite.
  • Test rune-based logic through component integration tests rather than in isolation. If a rune is complex, consider if it should be a pure function instead.
  • The trade-off is always speed versus accuracy: unit tests are fast but shallow; browser tests are slow but comprehensive. Match the tool to what you're actually trying to verify.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles