Testing
Three layers, three tools
Section titled “Three layers, three tools”Testing a Svelte app is the same pyramid as any frontend, with Svelte-aware helpers at the component layer:
| Layer | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Pure logic, utilities, and runed .svelte.js modules |
| Component | @testing-library/svelte + Vitest | A component rendered in jsdom — behaviour, not internals |
| End-to-end | Playwright | The 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)"]
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).
Unit tests with Vitest
Section titled “Unit tests with Vitest”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:
export function formatPrice(amount: number): string { return `$${amount.toFixed(2)}`;}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 symbolReactive 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.
Component tests with Testing Library
Section titled “Component tests with Testing Library”@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:
<script lang="ts"> let { start = 0 }: { start?: number } = $props(); let count = $state(start);</script>
<button onclick={() => count++}>count: {count}</button>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 clickThe 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.
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;}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 componentNote 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.
End-to-end with Playwright
Section titled “End-to-end with Playwright”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.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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Testing pure .ts logic with Vitest | Fast, no DOM, no component overhead (proven above) | Only covers logic that doesn’t touch state/effects/the DOM |
@testing-library/svelte querying by role/text | Survives internal refactors that don’t change behavior (proven above) | Slower than pure unit tests; needs jsdom |
$effect.root for .svelte.ts module tests | Lets you assert on effect-driven behavior (proven above) with no component at all | One more API to know, and you own calling the returned cleanup yourself — nothing does it for you |
| Playwright end-to-end | Only layer that proves the deployed app actually works, across real navigation | Slow, and flaky if not scoped tightly — keep this layer small |
Common Misconceptions
Section titled “Common Misconceptions”- “Runes can be tested directly inside a plain
.test.tsfile.” They cannot — proven above with the realrune_outside_svelteerror; the rune-using code has to live in a.svelte.tsfile, imported and called by the test, not written inline in the test itself. - “
$effect.rootis only for advanced/internal use, not something test code should reach for.” It’s the documented, supported way to give$effecta scope outside a component — proven above, it’s exactly what makes testing a.svelte.tsmodule’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.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated tests, check for:
- Does a test file try to call
$state/$derived/$effectdirectly, rather than importing them from a.svelte.tsmodule — a mistake that fails immediately withrune_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.tstest 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
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.