Skip to content

Testing

Testing a Svelte app is the same pyramid as any frontend, with Svelte-aware helpers at the component layer:

LayerToolWhat it covers
UnitVitestPure logic, utilities, and runed .svelte.js modules
Component@testing-library/svelte + VitestA component rendered in jsdom — behaviour, not internals
End-to-endPlaywrightThe real app in a real browser, across pages
flowchart TB
  e2e["Playwright: end-to-end (few)"] --> comp["Testing Library: components (some)"]
  comp --> unit["Vitest: logic + runed modules (many)"]
The testing pyramid for a Svelte app

This lesson’s own examples run for real: every fence below whose first line is a path comment (// src/lib/x.ts or <!-- src/lib/X.svelte -->) is collected by this course’s own harness (tools/verify-snippets.mjs) into a real SvelteKit project and type-checked with svelte-check; fences ending .test.ts are additionally run with vitest itself. “This example is a real, running test” isn’t a figure of speech anywhere in this lesson — it’s the literal mechanism proving every claim in this course that isn’t about the browser (Playwright’s layer, below).

Plain logic — formatters, calculations, a store’s update function — is tested with Vitest, which shares Vite’s config so it understands your project out of the box:

src/lib/money.ts
export function formatPrice(amount: number): string {
return `$${amount.toFixed(2)}`;
}
src/lib/money.test.ts
import { describe, it, expect } from 'vitest';
import { formatPrice } from './money.ts';
describe('formatPrice', () => {
it('adds a currency symbol', () => {
expect(formatPrice(5)).toBe('$5.00');
});
});
✓ formatPrice > adds a currency symbol

Reactive state in a .svelte.js / .svelte.ts module (runes outside components) is tested the same way — Vitest runs with the Svelte plugin so runes work in the test.

@testing-library/svelte renders a component into jsdom and gives you queries and interactions that mirror how a user behaves — query by role/text, not by internal class names:

src/lib/Counter.svelte
<script lang="ts">
let { start = 0 }: { start?: number } = $props();
let count = $state(start);
</script>
<button onclick={() => count++}>count: {count}</button>
src/lib/Counter.test.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';
test('increments on click', async () => {
render(Counter, { props: { start: 0 } });
const button = screen.getByRole('button', { name: /count/i });
await userEvent.click(button);
expect(button).toHaveTextContent('count: 1');
});
✓ increments on click

The discipline: test behaviour, not implementation. Assert on what the user sees and does (visible text, roles, interactions), so a refactor that keeps behaviour identical doesn’t break the test.

Testing runes outside components: .svelte.ts + $effect.root

Section titled “Testing runes outside components: .svelte.ts + $effect.root”

A .svelte.ts module can use runes directly, without a component — this course’s own createCounter-style factories are the usual shape. The one wrinkle: $effect needs somewhere to run, and outside a component that “somewhere” is $effect.root(fn) — it creates a non-component effect scope that you’re responsible for tearing down yourself (calling the function it returns), instead of a component’s own lifecycle doing it for you.

src/lib/runeCounter.svelte.ts
import { flushSync } from 'svelte';
export function createCounter(initial = 0) {
let count = $state(initial);
const doubled = $derived(count * 2);
return {
get count() { return count; },
get doubled() { return doubled; },
inc() { count += 1; },
};
}
// Runes ($effect.root, $effect) only work inside .svelte / .svelte.ts files,
// so the effect-driven assertions live here, not in the plain .test.ts file.
export function traceDoubled(): number[] {
const log: number[] = [];
const counter = createCounter(1);
const destroy = $effect.root(() => {
$effect(() => {
log.push(counter.doubled);
});
});
flushSync();
counter.inc();
flushSync();
destroy();
return log;
}
src/lib/runeCounter.test.ts
import { test, expect } from 'vitest';
import { traceDoubled } from './runeCounter.svelte.ts';
test('$derived recomputes inside a $effect.root-wrapped effect, outside any component', () => {
expect(traceDoubled()).toEqual([2, 4]);
});
✓ $derived recomputes inside a $effect.root-wrapped effect, outside any component

Note where the rune-using code lives: $effect.root/$effect are called from inside runeCounter.svelte.ts (a real .svelte.ts file, where runes are legal), not from the plain .test.ts file that imports and calls it — runes throw a real rune_outside_svelte error the moment they’re used anywhere else, including directly inside a test file.

Playwright drives a real browser through real pages — routing, form submission, navigation — which is exactly where SvelteKit’s server behaviour (load functions, form actions) needs verifying:

import { test, expect } from '@playwright/test';
test('user can sign in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});

Keep these few and high-value — they’re slower than unit and component tests, but they’re the only layer that proves the whole thing works together.

Three layers exist because they answer three different questions, and no single tool answers all of them cheaply: Vitest asks “does this function/module do the right thing,” Testing Library asks “does this component behave correctly when a user interacts with it, in a fake DOM,” and Playwright asks “does the whole deployed thing work, in a real browser, across real page loads.” Runes complicate exactly one of those layers structurally — $state/$derived/$effect only exist inside .svelte/.svelte.js/.svelte.ts files, so testing them outside a component (a .svelte.ts module) needs $effect.root to give $effect somewhere to run at all; the other two layers don’t see this because a component’s own lifecycle (Testing Library’s render()) or a real browser (Playwright) already provides that scope for you.

What you getBenefitCost
Testing pure .ts logic with VitestFast, no DOM, no component overhead (proven above)Only covers logic that doesn’t touch state/effects/the DOM
@testing-library/svelte querying by role/textSurvives internal refactors that don’t change behavior (proven above)Slower than pure unit tests; needs jsdom
$effect.root for .svelte.ts module testsLets you assert on effect-driven behavior (proven above) with no component at allOne more API to know, and you own calling the returned cleanup yourself — nothing does it for you
Playwright end-to-endOnly layer that proves the deployed app actually works, across real navigationSlow, and flaky if not scoped tightly — keep this layer small
  • “Runes can be tested directly inside a plain .test.ts file.” They cannot — proven above with the real rune_outside_svelte error; the rune-using code has to live in a .svelte.ts file, imported and called by the test, not written inline in the test itself.
  • “$effect.root is only for advanced/internal use, not something test code should reach for.” It’s the documented, supported way to give $effect a scope outside a component — proven above, it’s exactly what makes testing a .svelte.ts module’s effect-driven behavior possible at all.
  • “Testing behaviour instead of implementation means you can’t test anything specific.” Proven above: expect(button).toHaveTextContent('count: 1') is precise and specific — it just asserts on what a user would actually see, not on an internal variable’s name or shape.

When reviewing generated tests, check for:

  • Does a test file try to call $state/$derived/$effect directly, rather than importing them from a .svelte.ts module — a mistake that fails immediately with rune_outside_svelte, proven above?
  • Does a component test assert on internal state or a private variable (reaching into component internals) instead of the rendered output and interactions a real user would see, as @testing-library/svelte’s own philosophy demands?
  • Is $effect.root’s returned cleanup function never called — leaking an effect scope that a real component’s lifecycle would have torn down automatically, but a manual .svelte.ts test has to do itself?
  • Is an end-to-end Playwright test used to check something a unit or component test could verify far faster — inflating the “few, high-value” e2e layer into the bulk of the suite?

🐛 Spot the bug

src/lib/runeCounter.test.ts
import { test, expect } from 'vitest';
import { $state, $derived } from 'svelte';
test('doubled updates', () => {
let count = $state(1);
const doubled = $derived(count * 2);
count = 2;
expect(doubled).toBe(4);
});
Show the answer

Two mistakes compound here. First, runes aren’t importable values at all — $state/$derived are compiler intrinsics recognized by Svelte’s compiler only inside .svelte/.svelte.js/.svelte.ts files; import { $state } from 'svelte' doesn’t even correspond to a real export. Second, even fixed to use the rune syntax directly, this code is written inline in a plain .test.ts file, which fails with the real rune_outside_svelte error proven earlier in this lesson: `$effect`/`$state`/`$derived` are only available inside .svelte and .svelte.js/ts files.

The fix is the pattern proven above: move the rune-using logic into a real runeCounter.svelte.ts module (a function the test imports and calls), and only reach for $effect.root if the assertion actually depends on an effect running — a bare $derived read, like this one, doesn’t even need that much.

What is the guiding principle when testing components with Testing Library?
How do you test reactive state in a .svelte.js / .svelte.ts module?
Which tool verifies a full sign-in flow across real pages in a browser?
What happens when this runs?
// a test file
import { $state } from 'svelte';
let count = $state(1);
Why does testing a .svelte.ts module's $effect-driven behavior need $effect.root?
const destroy = $effect.root(() => {
  $effect(() => { log.push(counter.doubled); });
});