Testing Svelte Components With Vitest

Svelte 5 changed the component model underneath existing test suites. Props are declared with $props(), reactive state with $state, derived values with $derived, and slots have become snippets — and components are now functions mounted with mount rather than classes constructed with new. Tests written for Svelte 4 that instantiate components, set props with $set or listen with $on stop working, and the replacements are not always obvious. This guide covers testing Svelte 5 components with @testing-library/svelte and Vitest: configuration, props and $bindable, snippets through harness components, context, timing with flushSync, and testing rune-based shared state that lives in .svelte.ts modules. It sits under Vue and Svelte component testing.

Root Cause Analysis

The breakage from Svelte 4 to 5 is mostly about the component API surface that old tests relied on. component.$set(props) updated props; in Svelte 5 components have no such method, and updating props means rendering through something that owns them — Testing Library’s rerender, or a parent. component.$on('event', fn) listened for dispatched events; Svelte 5 replaces event dispatchers with callback props, so tests pass a function and assert it was called.

Snippets are the second change. A Svelte 4 test could pass slot content as a string; Svelte 5 snippets are compiled from markup, so the natural way to supply one in a test is a small harness component that renders the component under test with the snippet a real parent would provide.

The third is resolution. Svelte ships separate server and browser builds, selected by package export conditions. Vitest, running in Node, resolves the server build by default, and components then fail in confusing ways — lifecycle functions throw, mount is missing. The browser condition must be set explicitly, which the Testing Library plugin does for you.

Svelte 4 test idioms and their Svelte 5 replacements Constructing components with new becomes rendering with Testing Library, $set becomes rerender, $on becomes a callback prop, string slots become snippets supplied by a harness component, and tick becomes flushSync or awaited user events. Svelte 4 Svelte 5 new Component({ target, props }) render(Component, { props }) component.$set({ value }) rerender({ value }) component.$on('change', fn) props: { onchange: fn } slot content as a string snippet via a harness component await tick() awaited user-event, flushSync
Every Svelte 4 idiom has a direct replacement; most of them move the test closer to how a parent uses the component.

Reproducible Setup

Configure Vitest with the Svelte plugin and Testing Library’s plugin, which sets the browser condition and registers cleanup.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';

export default defineConfig({
  plugins: [svelte(), svelteTesting()],
  test: { environment: 'jsdom', setupFiles: ['./test/setup.ts'], include: ['src/**/*.test.ts'] },
});
<!-- src/lib/RatingInput.svelte -->
<script lang="ts">
  let { value = $bindable(0), max = 5, onchange }: { value?: number; max?: number; onchange?: (v: number) => void } = $props();
  const label = $derived(`${value} out of ${max} stars`);
  function set(v: number) { value = v; onchange?.(v); }
</script>

<div role="radiogroup" aria-label="Rating">
  {#each Array(max) as _, i}
    <button role="radio" aria-checked={value === i + 1} aria-label={`${i + 1} star${i ? 's' : ''}`} onclick={() => set(i + 1)}>★</button>
  {/each}
</div>
<p aria-live="polite">{label}</p>

Implementation

Step 1 — Render with props and query by role. The component’s radio semantics make role queries natural and robust.

// src/lib/RatingInput.test.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RatingInput from './RatingInput.svelte';

test('marks the current rating as checked', () => {
  render(RatingInput, { props: { value: 3 } });
  expect(screen.getByRole('radio', { name: '3 stars' })).toHaveAttribute('aria-checked', 'true');
  expect(screen.getByText('3 out of 5 stars')).toBeInTheDocument();
});

The live region holding the label is doing double duty here. It is how screen reader users hear the new rating, and it is also a stable, readable target for the assertion. Components designed with accessible output in mind tend to be easier to test for exactly this reason: the text a user would hear is the text a test can look for.

Step 2 — Assert on callback props instead of dispatched events. Svelte 5 components communicate upwards through functions passed as props.

test('reports the chosen rating', async () => {
  const onchange = vi.fn();
  render(RatingInput, { props: { value: 0, onchange } });
  await userEvent.setup().click(screen.getByRole('radio', { name: '4 stars' }));
  expect(onchange).toHaveBeenCalledWith(4);
  expect(screen.getByText('4 out of 5 stars')).toBeInTheDocument();
});

Callback props are a simplification for tests as well as for components. A Svelte 4 test had to subscribe to an event by name and remember to unsubscribe; a Svelte 5 test passes a spy and inspects its calls, which is the same pattern used for any function argument. Components migrating from createEventDispatcher gain a typed contract, and their tests lose a layer of ceremony at the same time.

Step 3 — Update props with rerender. A parent changing the value from outside is a distinct behaviour from the user changing it, and deserves its own test.

test('follows a value set by its parent', async () => {
  const { rerender } = render(RatingInput, { props: { value: 1 } });
  await rerender({ value: 5 });
  expect(screen.getByRole('radio', { name: '5 stars' })).toHaveAttribute('aria-checked', 'true');
});

Step 4 — Test $bindable and snippets through a harness. Two-way binding and snippet children are declared in a parent’s markup, so a small harness component is the clearest way to exercise them.

<!-- src/lib/RatingInput.harness.svelte -->
<script lang="ts">
  import RatingInput from './RatingInput.svelte';
  let { initial = 0 } = $props();
  let rating = $state(initial);
</script>

<RatingInput bind:value={rating} />
<output data-testid="bound">{rating}</output>
import Harness from './RatingInput.harness.svelte';

test('binds the rating back to its parent', async () => {
  render(Harness, { props: { initial: 2 } });
  await userEvent.setup().click(screen.getByRole('radio', { name: '5 stars' }));
  expect(screen.getByTestId('bound')).toHaveTextContent('5');
});
A harness component plays the parent The test renders a small harness that owns state, binds it to the component under test and exposes it in an output element, so two-way binding and snippet children are exercised exactly as a real parent would use them. test render(Harness) harness — plays the parent RatingInput bind:value output shows parent state assert on the output
The harness makes the parent's side of the contract observable, which a bare render cannot.

Keep harness components deliberately small and named for what they demonstrate, and place them beside the component they exercise. They are test code that happens to be written in Svelte, and a harness that grows its own logic starts to need tests of its own. One harness per contract — binding, a particular snippet, a particular context — keeps each one obvious at a glance.

Step 5 — Supply context with a Map. Components that call getContext receive it from the render options.

test('uses the currency from context', () => {
  render(PriceTag, { props: { pence: 1250 }, context: new Map([['currency', 'EUR']]) });
  expect(screen.getByText('€12.50')).toBeInTheDocument();
});

Step 6 — Test rune-based shared state as a plain module. State in a .svelte.ts module is ordinary code with reactivity; test it without rendering, and export a factory so each test gets a fresh instance.

// src/lib/cart.svelte.ts
export function createCart() {
  let lines = $state<Array<{ sku: string; qty: number }>>([]);
  const count = $derived(lines.reduce((n, l) => n + l.qty, 0));
  return {
    get lines() { return lines; },
    get count() { return count; },
    add(sku: string) { const l = lines.find((x) => x.sku === sku); if (l) l.qty++; else lines.push({ sku, qty: 1 }); },
  };
}
// src/lib/cart.svelte.test.ts
import { flushSync } from 'svelte';
import { createCart } from './cart.svelte';

test('counts quantities across lines', () => {
  const cart = createCart();
  cart.add('MUG'); cart.add('MUG'); cart.add('TEE');
  flushSync();
  expect(cart.count).toBe(3);
});

The test file itself uses the .svelte.test.ts suffix so the compiler processes runes in it.

Exporting a factory rather than a module-level instance is the design choice that makes this testable, and it costs production code nothing: the application creates one instance at the top of the tree and shares it through context, while each test creates its own. A module-level $state shared by every importer is convenient until the first test that depends on it being empty runs after one that filled it.

Verification

Confirm the configuration resolves the browser build by rendering any component that uses a lifecycle function. If it throws about server rendering, the browser condition is missing.

npx vitest run src/lib --reporter=verbose
# ✓ marks the current rating as checked
# ✓ reports the chosen rating
# ✓ follows a value set by its parent
# ✓ binds the rating back to its parent

Then confirm the binding test is meaningful by removing $bindable from the prop declaration. The harness test must fail — the parent’s output stays at its initial value — which proves the test checks two-way binding rather than only the component’s own display.

Where each piece of Svelte code is tested Components are rendered with Testing Library, bindings and snippets are exercised through harness components, and rune-based state in .svelte.ts modules is tested directly without rendering. components render, query by role callback props, rerender bindings, snippets harness components that play the parent .svelte.ts state plain module tests fresh instance each time
Three kinds of Svelte code, three testing approaches — all sharing the same runner and matchers.

Troubleshooting

Symptom: “lifecycle_function_unavailable” or mount is not exported. Diagnosis: Vitest resolved Svelte’s server build. Fix: add svelteTesting() to the plugins, or add browser to resolve.conditions by hand.

Symptom: runes throw “not defined” in a test file. Diagnosis: the test file uses runes but is not compiled by Svelte. Fix: name it *.svelte.test.ts so the Svelte plugin processes it, and include that pattern in the test globs.

Symptom: state changes but the DOM does not update before the assertion. Diagnosis: the change happened outside a user event and was not flushed. Fix: call flushSync() after programmatic state changes, or await the user-event interaction that caused them.

Symptom: state leaks between tests. Diagnosis: the .svelte.ts module exports a single instance created at module scope. Fix: export a factory, as in Step 6, and create a fresh instance per test — the same principle as a fresh Pinia in Vue.

FAQ

Can I still test Svelte 4 components?

Yes, with Testing Library’s legacy support, but Svelte 5 in legacy mode already accepts most Svelte 4 syntax. Migrate tests away from $set and $on as components move to runes, since those APIs are the first to disappear.

Should I use happy-dom instead of jsdom?

For many Svelte components happy-dom is faster and entirely adequate. Where a component relies on less common DOM APIs, jsdom’s broader coverage avoids surprises; the trade-offs are discussed in choosing between jsdom, happy-dom and browser mode.

How do I test SvelteKit load functions?

They are plain functions: call them with a constructed event object and assert on the returned data. Keep them free of component concerns so they can be tested without rendering, and test the page component separately with its data passed as props.

What about transitions and animations?

jsdom does not run them. Pass zero durations in tests — many components accept a duration prop — or check prefers-reduced-motion, and verify real animation behaviour, if it matters, in a browser.