From JSDOM to Real Browsers: Testing Svelte with Vitest Browser Mode
In this post I’m going to go through converting the SvelteKit minimal
template from using @testing-library/svelte and jsdom over to
using @vitest/browser, vitest-browser-svelte and playwright.
Why? Well, it’s as close to testing components and pages as you can
get, rather than relying on the simulated jsdom it’s using
Playwright.
I’m currently working on a large monorepo where I’m leading the improvement on the testing posture for the teams there. There’s currently four apps, and we’re focusing on two right now with a combined 6k tests! 😅
The slowest part, running the server tests, client test’s run with vitest-browser-svelte runs super fast!
So, at the time of writing this @testing-library/svelte and jsdom are still the default when starting out with a new project (and
probably what you’re testing with now). From Svelte ambassador
discussions I have been involved with this may change in the future,
so I’ll also be detailing some common testing patterns and a good
testing strategy to set you up.
You can always check out the migration post I did for this site for more details Migrating from @testing-library/svelte to vitest-browser-svelte.
Important: This guide reflects vitest-browser-svelte v0.1.0 limitations. Universal state runes require
flushSync()to trigger DOM updates in tests - the locators alone won’t automatically wait for external state changes. The testing patterns work well, but you might notice minor differences in HTML output format (like self-closing tags or CSS class ordering) between examples and actual results.
The current canonical testing documentation around this is from David Peng, after I updated the sveltest repo and shared it on Bluesky it looks like I have been given his blessing to lead the way on this now 😅
This also encouraged me to create the sveltest.dev site which is essentially everything I have been learning over the past several weeks on using Vitest Browser
Aight, preamble over! Let’s get started with this then!
Create a new SvelteKit project
Right, let’s get this party started! I use pnpm as my preferred package manager, so I’ll be using it throughout these examples. Let me bootstrap a project:
1pnpm dlx sv@latest create testing-with-vitest-browser-svelte
2
3# Here's the options I'm picking
4┌ Welcome to the Svelte CLI! (v0.8.10)
5◆ Which template would you like?
6│ SvelteKit minimal
7◆ Add type checking with TypeScript?
8│ Yes, using TypeScript syntax
9◆ What would you like to add to your project? (use arrow keys / space bar)
10│ prettier, eslint, vitest, playwright, tailwindcss
11◆ tailwindcss: Which plugins would you like to add?
12│ typography
13◆ Which package manager do you want to install dependencies with?
14│ ● pnpm
15└I’ll also be using daisyUI - that’s just an extra line in the app.css once I’ve got it installed:
1@import 'tailwindcss';
2@plugin '@tailwindcss/typography';
3@plugin 'daisyui';Onward!
Install vitest-browser-svelte
Install the deps I’m going to need, uninstall the ones I won’t be needing anymore!
1cd testing-with-vitest-browser-svelte
2# Add vitest browser, Svelte testing and playwright
3pnpm install -D @vitest/browser vitest-browser-svelte playwright
4
5# remove testing library and jsdom
6pnpm un @testing-library/jest-dom @testing-library/svelte jsdom
7
8# Install Playwright browsers (required for browser testing!)
9pnpm exec playwright installImportant! The pnpm exec playwright install step is crucial -
without it, browser tests will fail with “No browsers found” errors.
Now running pnpm run test:unit is going to fail because I’ve not
configured anything!
Configure Vitest for browser testing
I need to completely replace the vite.config.ts file. The SvelteKit
template comes with testing-library configuration, but I’m switching
to browser testing, so I’ll wipe it clean and start fresh:
1import tailwindcss from '@tailwindcss/vite';
2import { sveltekit } from '@sveltejs/kit/vite';
3import { defineConfig } from 'vite';
4
5export default defineConfig({
6 plugins: [tailwindcss(), sveltekit()],
7
8 test: {
9 projects: [
10 {
11 // Client-side tests (Svelte components)
12 extends: './vite.config.ts',
13 test: {
14 name: 'client',
15 environment: 'browser',
16 // Timeout for browser tests - prevent hanging on element lookups
17 testTimeout: 2000,
18 browser: {
19 enabled: true,
20 provider: 'playwright',
21 // Multiple browser instances for better performance
22 // Uses single Vite server with shared caching
23 instances: [
24 { browser: 'chromium' },
25 // { browser: 'firefox' },
26 // { browser: 'webkit' },
27 ],
28 },
29 include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
30 exclude: [
31 'src/lib/server/**',
32 'src/**/*.ssr.{test,spec}.{js,ts}',
33 ],
34 setupFiles: ['./vitest-setup-client.ts'],
35 },
36 },
37 {
38 // SSR tests (Server-side rendering)
39 extends: './vite.config.ts',
40 test: {
41 name: 'ssr',
42 environment: 'node',
43 include: ['src/**/*.ssr.{test,spec}.{js,ts}'],
44 },
45 },
46 {
47 // Server-side tests (Node.js utilities)
48 extends: './vite.config.ts',
49 test: {
50 name: 'server',
51 environment: 'node',
52 include: ['src/**/*.{test,spec}.{js,ts}'],
53 exclude: [
54 'src/**/*.svelte.{test,spec}.{js,ts}',
55 'src/**/*.ssr.{test,spec}.{js,ts}',
56 ],
57 },
58 },
59 ],
60 },
61});Now I need to set up the proper test scripts in package.json. I’ll
add these scripts to run the different project configurations:
1{
2 "scripts": {
3 "test": "vitest",
4 "test:client": "vitest --project=client",
5 "test:server": "vitest --project=server",
6 "test:ssr": "vitest --project=ssr",
7 "test:e2e": "playwright test"
8 }
9}It’s not wildly different to the current setup! Client environment is switched from jsdom over to browser and I’ve added an aggressive timeout on tests for that. I’ve also added in some SSR config!
I’ll also replace the contents of the vitest-setup-client.ts file so
there’s no mocks, I’m just referencing Vitest browser here!
1/// <reference types="@vitest/browser/matchers" />
2/// <reference types="@vitest/browser/providers/playwright" />For more on this watch the awesome talk from Dominik G at Svelte Summit Testing 1 2 3 4. Which I’ll credit as what inspired me to create sveltest.dev.
Get the current tests working
So, running pnpm run test:unit is still going to cause issues
because I’ve still got references to testing library which I
uninstalled!
1[vite] Internal server error: Failed to resolve import "@testing-library/jest-dom/vitest" from "src/routes/page.svelte.test.ts". Does the file exist?
2 Plugin: vite:import-analysis
3 File: /home/testing-with-vitest-browser-svelte/src/routes/page.svelte.test.ts:2:7
4 2 | import * as $ from 'svelte/internal/client';
5 3 | import { describe, test, expect } from "vitest";
6 4 | import "@testing-library/jest-dom/vitest";
7 | ^
8 5 | import { render, screen } from "@testing-library/svelte";
9 6 | import Page from "./+page.svelte";So, swap out testing library with Vitest browser now in the src/routes/page.svelte.test.ts file:
1import { page } from '@vitest/browser/context';
2import { describe, expect, it } from 'vitest';
3import { render } from 'vitest-browser-svelte';
4import Page from './+page.svelte';
5
6describe('/+page.svelte', () => {
7 it('should render h1', async () => {
8 render(Page);
9
10 const heading = page.getByRole('heading', { level: 1 });
11 await expect.element(heading).toBeInTheDocument();
12 });
13});Now pnpm run test:unit passes!! Success!
Aight! So, let’s go through some of the examples in sveltest now!
Make a button and test it
1<script lang="ts">
2 interface Props {
3 variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
4 size?: 'sm' | 'md' | 'lg';
5 disabled?: boolean;
6 loading?: boolean;
7 onclick?: () => void;
8 type?: 'button' | 'submit' | 'reset';
9 class_names?: string;
10 children?: any;
11 }
12
13 let {
14 variant = 'primary',
15 size = 'md',
16 disabled = false,
17 loading = false,
18 onclick,
19 type = 'button',
20 class_names = '',
21 children,
22 }: Props = $props();
23
24 const base_classes = 'btn transition-all duration-200';
25 const variant_classes = {
26 primary: 'btn-primary hover:scale-105',
27 secondary: 'btn-secondary hover:scale-105',
28 outline: 'btn-outline hover:scale-105',
29 ghost: 'btn-ghost hover:scale-105',
30 };
31 const size_classes = {
32 sm: 'btn-sm',
33 md: '',
34 lg: 'btn-lg',
35 };
36</script>
37
38<button
39 {type}
40 class={[
41 base_classes,
42 variant_classes[variant],
43 size_classes[size],
44 class_names,
45 ]}
46 {disabled}
47 {onclick}
48 aria-disabled={disabled || loading}
49>
50 {#if loading}
51 <span class="loading loading-sm loading-spinner"></span>
52 Loading...
53 {:else}
54 {@render children?.()}
55 {/if}
56</button>Sweet! Let me create this as a new file in the src/lib/components/ directory. I’ll also add an export from index.ts so I can use it:
1# Create the components directory first
2mkdir -p src/lib/components
3touch src/lib/components/button.svelte
4touch src/lib/components/index.tsAnd the export from src/lib/components/index.ts:
1export { default as Button } from './button.svelte';Now, let’s get into testing this! This is where the Client-Server Alignment Strategy comes in! 🚀
The Client-Server Alignment Strategy
Right, this is the approach I’m using at work and what’s detailed on sveltest.dev. The idea is simple: test where you run!
- Client tests (
.svelte.test.ts) - Test UI components, user interactions, and anything that runs in the browser - Server tests (
.test.ts) - Test server utilities, API logic, and pure functions that run in Node.js - E2E tests - Test the whole application flow with Playwright
This alignment means no more trying to mock browser APIs in Node or server APIs in the browser. Each environment tests what it’s designed to handle!
Testing the button component
Right, let’s write some tests for this button! I’ll create src/lib/components/button.svelte.test.ts:
1touch src/lib/components/button.svelte.test.tsThen:
1import { page } from '@vitest/browser/context';
2import { createRawSnippet } from 'svelte';
3import { describe, expect, it } from 'vitest';
4import { render } from 'vitest-browser-svelte';
5import Button from './button.svelte';
6
7describe('Button Component', () => {
8 it('renders with default props', async () => {
9 const children = createRawSnippet(() => ({
10 render: () => '<span>Click me</span>',
11 setup: () => {},
12 }));
13
14 render(Button, {
15 children,
16 });
17
18 const button = page.getByRole('button');
19 await expect.element(button).toBeInTheDocument();
20 await expect.element(button).toHaveTextContent('Click me');
21 await expect.element(button).toHaveClass('btn-primary');
22 });
23
24 it.skip('applies different variants', () => {
25 // Pattern: Test component props that change CSS classes
26 // render(Button, { variant: 'secondary' })
27 // await expect.element(button).toHaveClass('btn-secondary')
28 });
29
30 it.skip('shows loading state', () => {
31 // Pattern: Test conditional rendering based on props
32 // render(Button, { loading: true })
33 // await expect.element(button).toHaveTextContent('Loading...')
34 });
35
36 it('handles click events', async () => {
37 let clicked = false;
38 const handle_click = () => {
39 clicked = true;
40 };
41
42 const children = createRawSnippet(() => ({
43 render: () => '<span>Click me</span>',
44 setup: () => {},
45 }));
46
47 render(Button, {
48 onclick: handle_click,
49 children,
50 });
51
52 const button = page.getByRole('button');
53 await button.click();
54
55 // Testing the state change - this is where real browser testing shines!
56 expect(clicked).toBe(true);
57 });
58
59 it.skip('is disabled when disabled prop is true', () => {
60 // Pattern: Test accessibility attributes
61 // render(Button, { disabled: true })
62 // await expect.element(button).toBeDisabled()
63 // await expect.element(button).toHaveAttribute('aria-disabled', 'true')
64 });
65});Running pnpm run test:client and these tests pass! Real browser
testing in action! 🎉
Note: You might see some warnings about createRawSnippet expecting HTML for a single element - that’s why I’m wrapping the text
in <span> tags. This keeps the Svelte 5 snippet system happy!
A note on the test examples
Right, before we dive into more code examples, I want to mention
something about the test patterns you’ll see. Throughout this guide,
I’m going to show you the core testing patterns once, and then use it.skip() for similar tests to avoid repetition.
When you see an it.skip() test, it’s not broken - it’s intentionally
skipped with comments showing what pattern it would follow. This keeps
the guide focused on the essential concepts without drowning you in
repetitive test code.
If you want to see all the tests implemented in full, check out sveltest.dev where every pattern is shown completely. Think of this guide as the “why and how” and sveltest.dev as the “show me everything” resource.
The patterns I’ll show once and then reference with it.skip():
- Component prop variations (different variants, sizes, etc.)
- State manipulation patterns (increment, decrement, reset)
- Form validation edge cases
- SSR rendering with different props
- E2E interaction patterns
This approach lets me keep your attention on the important stuff - the Client-Server Alignment Strategy and the key insights for testing Svelte 5 with real browsers!
Testing best practices (the stuff that’ll save you headaches)
Right, before we get into the meat of the testing examples, let me share some hard-earned wisdom from my real-world testing experience. These are the gotchas that’ll trip you up if you don’t know about them!
Always use locators, never containers
This is the big one! I cannot stress this enough - always use page.getBy*() locators, never use containers. Here’s why:
1// ❌ DON'T do this - no auto-retry, will randomly fail
2const { container } = render(MyComponent);
3const button = container.querySelector('[data-testid="submit"]');
4await button.click(); // This will bite you!
5
6// ✅ DO this - auto-retry built in, much more reliable
7render(MyComponent);
8const button = page.getByTestId('submit');
9await button.click(); // Rock solid!Locators have automatic retry logic built in, which means they’ll wait for elements to appear in the DOM. Containers don’t have this magic, so you’ll get flaky tests that fail randomly. Trust me, I’ve been there!
Locator priority order
When you’re picking locators, follow this hierarchy for the best accessibility and reliability:
1// 1. Semantic roles (best for accessibility)
2page.getByRole('button', { name: 'Submit' });
3
4// 2. Labels (great for form fields)
5page.getByLabel('Email address');
6
7// 3. Text content (good for unique text)
8page.getByText('Welcome back');
9
10// 4. Test IDs (last resort, but reliable)
11page.getByTestId('submit-button');Handle multiple elements properly
This one caught me out! When multiple elements match your locator, you’ll get a “strict mode violation” error. Here’s the fix:
1// ❌ FAILS: "strict mode violation" when multiple links exist
2page.getByRole('link', { name: 'Home' });
3
4// ✅ CORRECT: Be specific about which one you want
5page.getByRole('link', { name: 'Home' }).first();
6page.getByRole('link', { name: 'Home' }).nth(1); // second one
7page.getByRole('link', { name: 'Home' }).last();Never click form submit buttons
This is a sneaky one that’ll cause your tests to hang! Don’t click form submit buttons directly:
1// ❌ DON'T - causes test hangs
2const submitButton = page.getByRole('button', { type: 'submit' });
3await submitButton.click(); // Test hangs here!
4
5// ✅ DO - test the form state instead
6render(ContactForm, {
7 form: { errors: { email: 'Required' } },
8});
9await expect.element(page.getByText('Required')).toBeInTheDocument();Use untrack() for derived values
When testing Svelte 5 $derived values, always wrap them in untrack():
1// ❌ This might not work reliably
2expect(counter_state.doubled).toBe(6);
3
4// ✅ Always use untrack for derived values
5expect(untrack(() => counter_state.doubled)).toBe(6);Don’t test implementation details
Focus on user behavior, not internal implementation:
1// ❌ Testing implementation details (SVG paths, CSS classes)
2expect(body).toContain('M9 12l2 2 4-4m6 2a9');
3expect(button).toHaveClass('bg-blue-500 hover:bg-blue-600');
4
5// ✅ Test user-facing behavior
6await expect
7 .element(page.getByRole('img', { name: /success/i }))
8 .toBeInTheDocument();
9await expect.element(page.getByRole('button')).toBeEnabled();SvelteKit mocking - keep it simple
For SvelteKit apps, keep your mocks simple and avoid importOriginal with SvelteKit modules:
1// ✅ Simple and reliable
2vi.mock('$app/state', () => ({
3 page: {
4 data: { user: { name: 'Test User' } },
5 url: new URL('http://localhost'),
6 },
7}));
8
9// ❌ Causes SSR issues
10vi.mock('$app/stores', async (importOriginal) => {
11 return { ...(await importOriginal()) }; // Don't do this!
12});Ignore SSR module warnings in browser tests
You might see warnings like this when running browser tests:
1Error when evaluating SSR module: Cannot read properties of undefined (reading 'wrapDynamicImport')Don’t panic! These are expected during the transition to browser testing and don’t affect your test results. They’re just noise in the output from SvelteKit trying to evaluate server modules in the browser context. Your tests will still pass fine.
These practices will save you hours of debugging flaky tests. I learned most of these the hard way, so you don’t have to! Right, now let’s get into the fun stuff…
Testing Svelte 5 runes and universal state
Now let’s get into the really exciting stuff - testing Svelte 5’s
runes! One of the coolest features is universal state using *.svelte.ts files. This is perfect for testing reactive state
management.
Important caveat: Universal state from external *.svelte.ts files requires flushSync() to trigger DOM updates in browser tests.
The automatic retry behavior of locators only works for
component-internal reactivity.
Let me create a universal state store for managing a counter. I’ll
create src/lib/stores/counter.svelte.ts:
1mkdir -p src/lib/stores1// src/lib/stores/counter.svelte.ts
2class CounterStore {
3 count = $state(0);
4 multiplier = $state(2);
5
6 doubled = $derived(this.count * this.multiplier);
7 is_even = $derived(this.count % 2 === 0);
8
9 increment() {
10 this.count++;
11 }
12
13 decrement() {
14 this.count--;
15 }
16
17 reset() {
18 this.count = 0;
19 }
20
21 setMultiplier(value: number) {
22 this.multiplier = value;
23 }
24}
25
26export const counter_state = new CounterStore();This gives me a proper universal state store with reactive values, derived state, and methods. Now let’s create a component that uses this store:
1<!-- src/lib/components/counter.svelte -->
2<script lang="ts">
3 import { counter_state } from '$lib/stores/counter.svelte.js';
4</script>
5
6<div class="card w-96 bg-base-100 shadow-xl">
7 <div class="card-body">
8 <h2 class="card-title">Counter: {counter_state.count}</h2>
9 <p>Doubled: {counter_state.doubled}</p>
10 <p>Is Even: {counter_state.isEven ? 'Yes' : 'No'}</p>
11 <p>Multiplier: {counter_state.multiplier}</p>
12
13 <div class="card-actions justify-end">
14 <button
15 class="btn btn-primary"
16 onclick={() => counter_state.increment()}
17 data-testid="increment-btn"
18 >
19 +1
20 </button>
21 <button
22 class="btn btn-secondary"
23 onclick={() => counter_state.decrement()}
24 data-testid="decrement-btn"
25 >
26 -1
27 </button>
28 <button
29 class="btn btn-neutral"
30 onclick={() => counter_state.reset()}
31 data-testid="reset-btn"
32 >
33 Reset
34 </button>
35 </div>
36
37 <div class="form-control">
38 <label class="label" for="multiplier">
39 <span class="label-text">Multiplier</span>
40 </label>
41 <input
42 id="multiplier"
43 type="number"
44 class="input-bordered input"
45 bind:value={counter_state.multiplier}
46 data-testid="multiplier-input"
47 />
48 </div>
49 </div>
50</div>Now for the testing! This is where it gets really interesting. The key insight here is that Svelte 5 runes only work in browser/component environments, not in plain Node.js.
So I need to test the universal state where the runes actually work -
in the browser! I’ll create src/lib/components/counter.svelte.test.ts that tests both the
component AND the underlying state:
1import { page } from '@vitest/browser/context'
2import { describe, expect, it, beforeEach } from 'vitest'
3import { render } from 'vitest-browser-svelte'
4import { flushSync } from 'svelte'
5import { counter_state } from '$lib/stores/counter.svelte.js'
6import Counter from './counter.svelte'
7
8describe('Counter Component + Universal State', () => {
9 beforeEach(() => {
10 // Reset state before each test
11 counter_state.reset()
12 counter_state.setMultiplier(2)
13 })
14
15 describe('Universal State (tested via component)', () => {
16 it('initializes with correct default values', async () => {
17 render(Counter)
18
19 // Test that both state and UI reflect initial values
20 expect(counter_state.count).toBe(0)
21 expect(counter_state.multiplier).toBe(2)
22 expect(counter_state.doubled).toBe(0)
23 expect(counter_state.is_even).toBe(true)
24
25 // Verify UI reflects these values
26 await expect.element(page.getByText('Counter: 0')).toBeInTheDocument()
27 await expect.element(page.getByText('Doubled: 0')).toBeInTheDocument()
28 await expect.element(page.getByText('Is Even: Yes')).toBeInTheDocument()
29 })
30
31 it.skip('reactive state updates correctly', () => {
32 // Pattern: Direct state manipulation + flushSync for external state
33 // counter_state.increment()
34 // flushSync() // Required for external universal state
35 // expect(counter_state.count).toBe(1)
36 // await expect.element(page.getByText('Counter: 1')).toBeInTheDocument()
37 })
38
39 it.skip('derived state recalculates automatically', () => {
40 // Pattern: Test derived values update when dependencies change
41 // counter_state.increment()
42 // counter_state.setMultiplier(3)
43 // flushSync()
44 // expect(counter_state.doubled).toBe(3) // 1 * 3
45 })
46 })
47
48 it('increments counter when increment button is clicked', async () => {
49 render(Counter)
50
51 const incrementBtn = page.getByTestId('increment-btn')
52 await incrementBtn.click()
53
54 // External state ALWAYS needs flushSync, even with click events!
55 flushSync()
56
57 // Test that both the state and UI update
58 expect(counter_state.count).toBe(1)
59 await expect.element(page.getByText('Counter: 1')).toBeInTheDocument()
60 await expect.element(page.getByText('Doubled: 2')).toBeInTheDocument()
61 await expect.element(page.getByText('Is Even: No')).toBeInTheDocument()
62 })
63
64 it.skip('decrements counter when decrement button is clicked', () => {
65 // Pattern: Same as increment but testing decrement functionality
66 // Sets up initial state, clicks button, verifies result
67 })
68
69 it.skip('resets counter when reset button is clicked', () => {
70 // Pattern: Testing state reset functionality
71 // Set non-zero state, click reset, verify back to initial state
72 })
73
74 it.skip('updates multiplier through input field', () => {
75 // Pattern: Testing form input binding with state
76 // Fill input, trigger change, verify state and derived values update
77 })
78
79 it.skip('reactive derived state updates in real-time', () => {
80 // Pattern: Testing multiple state changes and their effects
81 // Click increment multiple times, verify derived state (is_even) toggles
82 })
83 })
84})Don’t forget to add the Counter component to the exports in src/lib/components/index.ts:
1export { default as Button } from './button.svelte';
2export { default as Counter } from './counter.svelte';What makes this testing approach powerful
This demonstrates the power of the Client-Server Alignment Strategy with Svelte 5:
- Reactive state tested in browser environment - Where runes actually work!
- Component + state integration tested together - Complete behavior verification
- Universal state works seamlessly across components (with proper
flushSync()usage) - No mocking needed - The same state instance works everywhere
- Clear patterns - External state always needs
flushSync(), internal component state updates automatically
Key insights for testing Svelte 5 runes:
- External state ALWAYS requires
flushSync(): When testing universal state from*.svelte.tsfiles, you needflushSync()to trigger DOM updates after ANY state manipulation - even click events! - Component-internal state works automatically: Only state that lives inside the component itself gets automatic reactivity updates
- Test in browser environment: Runes require a component context and don’t work in plain Node.js
The *.svelte.ts universal state is particularly brilliant because:
- Shared reactive state across your entire app
- Type-safe with full TypeScript support
- Testable with real reactivity in browser tests (with
flushSync()for external updates) - Works in SSR and hydration seamlessly
This approach gives you confidence that your reactive state management
works correctly, just remember the flushSync() requirement for
external state testing!
Testing SSR (Server-Side Rendering)
Now let’s cover SSR testing! This is crucial for ensuring your
components render correctly on the server and deliver proper HTML to
users. SSR tests run in Node.js and use Svelte’s built-in render function.
Let me create some components that benefit from SSR testing. First, let’s make a SEO component that should render properly on the server:
1mkdir -p src/lib/components/seo1<!-- src/lib/components/seo/meta-tags.svelte -->
2<script lang="ts">
3 interface Props {
4 title: string;
5 description: string;
6 url?: string;
7 image?: string;
8 type?: 'website' | 'article';
9 }
10
11 let {
12 title,
13 description,
14 url = 'https://example.com',
15 image = '/default-og.png',
16 type = 'website',
17 }: Props = $props();
18</script>
19
20<svelte:head>
21 <title>{title}</title>
22 <meta name="description" content={description} />
23
24 <!-- Open Graph -->
25 <meta property="og:title" content={title} />
26 <meta property="og:description" content={description} />
27 <meta property="og:url" content={url} />
28 <meta property="og:image" content={image} />
29 <meta property="og:type" content={type} />
30
31 <!-- Twitter -->
32 <meta name="twitter:card" content="summary_large_image" />
33 <meta name="twitter:title" content={title} />
34 <meta name="twitter:description" content={description} />
35 <meta name="twitter:image" content={image} />
36</svelte:head>And a blog post component that uses our universal state:
1<!-- src/lib/components/blog-post.svelte -->
2<script lang="ts">
3 import { counter_state } from '$lib/stores/counter.svelte.js';
4 import MetaTags from './seo/meta-tags.svelte';
5
6 interface Props {
7 title: string;
8 content: string;
9 author: string;
10 publishedAt: string;
11 slug: string;
12 }
13
14 let { title, content, author, publishedAt, slug }: Props = $props();
15
16 const url = `https://example.com/posts/${slug}`;
17 const reading_time = Math.ceil(content.split(' ').length / 200);
18</script>
19
20<MetaTags
21 {title}
22 description={content.slice(0, 160) + '...'}
23 {url}
24 type="article"
25/>
26
27<article class="mx-auto prose lg:prose-xl">
28 <header class="mb-8">
29 <h1 class="mb-4 text-4xl font-bold">{title}</h1>
30 <div class="mb-4 text-gray-600">
31 <span>By {author}</span>
32 <span class="mx-2">•</span>
33 <time datetime={publishedAt}>
34 {new Date(publishedAt).toLocaleDateString()}
35 </time>
36 <span class="mx-2">•</span>
37 <span>{reading_time} min read</span>
38 </div>
39
40 <!-- Show current counter state (for demo purposes) -->
41 <div class="rounded bg-blue-50 p-4">
42 <p class="text-sm">
43 Page views simulation: {counter_state.count}
44 <button
45 class="btn ml-2 btn-primary btn-xs"
46 onclick={() => counter_state.increment()}
47 >
48 +1
49 </button>
50 </p>
51 </div>
52 </header>
53
54 <div class="content">
55 {@html content}
56 </div>
57</article>Now let’s test the SSR rendering! I’ll create src/lib/components/seo/meta-tags.ssr.test.ts:
1import { render } from 'svelte/server';
2import { describe, expect, it } from 'vitest';
3import MetaTags from './meta-tags.svelte';
4
5describe('MetaTags SSR', () => {
6 it('renders basic meta tags', () => {
7 const { head } = render(MetaTags, {
8 props: {
9 title: 'Test Blog Post',
10 description: 'This is a test description for SEO purposes.',
11 },
12 });
13
14 // Check that essential meta tags are rendered
15 expect(head).toContain('<title>Test Blog Post</title>');
16 expect(head).toContain(
17 '<meta name="description" content="This is a test description for SEO purposes.">',
18 );
19
20 // Check Open Graph tags
21 expect(head).toContain(
22 '<meta property="og:title" content="Test Blog Post">',
23 );
24 expect(head).toContain(
25 '<meta property="og:description" content="This is a test description for SEO purposes.">',
26 );
27 expect(head).toContain(
28 '<meta property="og:type" content="website">',
29 );
30
31 // Check Twitter tags
32 expect(head).toContain(
33 '<meta name="twitter:card" content="summary_large_image">',
34 );
35 expect(head).toContain(
36 '<meta name="twitter:title" content="Test Blog Post">',
37 );
38 });
39
40 it.skip('renders with custom URL and image', () => {
41 // Pattern: Same as basic render test but with different props
42 // Tests prop customization and default value overrides
43 });
44
45 it.skip('uses default values when not provided', () => {
46 // Pattern: Test component default prop values
47 // Render with minimal props, verify defaults are applied
48 });
49});And let’s test the blog post component with SSR - src/lib/components/blog-post.ssr.test.ts:
1import { render } from 'svelte/server';
2import { describe, expect, it, beforeEach } from 'vitest';
3import { counter_state } from '$lib/stores/counter.svelte.js';
4import BlogPost from './blog-post.svelte';
5
6describe('BlogPost SSR', () => {
7 beforeEach(() => {
8 counter_state.reset();
9 });
10
11 it('renders blog post structure', () => {
12 const { body } = render(BlogPost, {
13 props: {
14 title: 'My Test Post',
15 content: '<p>This is the content of my blog post.</p>',
16 author: 'Scott Spence',
17 publishedAt: '2025-06-18',
18 slug: 'my-test-post',
19 },
20 });
21
22 // Check main content
23 expect(body).toContain(
24 '<h1 class="text-4xl font-bold mb-4">My Test Post</h1>',
25 );
26 expect(body).toContain('<span>By Scott Spence</span>');
27 expect(body).toContain(
28 '<p>This is the content of my blog post.</p>',
29 );
30 });
31
32 it.skip('calculates reading time correctly', () => {
33 // Pattern: Test computed/derived values in SSR
34 // Create content with known word count, verify reading time calculation
35 });
36
37 it.skip('formats date correctly', () => {
38 // Pattern: Test date formatting in SSR context
39 // Provide date string, verify formatted output in rendered HTML
40 });
41
42 it.skip('includes counter state from universal store', () => {
43 // Pattern: Test universal state works in SSR
44 // Render component, verify store state appears in server-rendered HTML
45 });
46
47 it.skip('renders meta tags in head', () => {
48 // Pattern: Test child component integration in SSR
49 // Verify that nested MetaTags component renders in head section
50 });
51});Don’t forget to add the exports to your components index:
1export { default as Button } from './button.svelte';
2export { default as Counter } from './counter.svelte';
3export { default as BlogPost } from './blog-post.svelte';
4export { default as MetaTags } from './seo/meta-tags.svelte';When to write SSR tests
Based on best practices and real-world experience, you should prioritize SSR tests for:
High Priority - Always Test:
- SEO-critical components - Meta tags, titles, Open Graph, structured data
- Initial page load content - Hero sections, navigation, critical above-the-fold content
- Universal state initialization - Ensure stores work server-side
- Dynamic content generation - Blog posts, product pages with server-generated content
Medium Priority - Test When Relevant:
- Content that affects accessibility - Proper heading hierarchy, alt tags
- Conditional rendering - Different content based on user state or data
- Date/time formatting - Ensure consistent formatting across server/client
- Calculated values - Reading time, pricing, derived data
Low Priority - Optional:
- Pure UI components - Buttons, modals that don’t affect SEO
- Interactive-only features - Client-side only functionality
- Development/debug components - Counter examples, dev tools
When NOT to write SSR tests:
- Client-side only interactions (hover states, animations)
- Components that only render after user interaction
- Third-party widgets that don’t affect initial load
Why SSR testing matters
SSR tests are crucial because they:
- Validate SEO - Ensure meta tags, titles, and structured data render correctly for search engines
- Test initial state - Verify server-rendered HTML matches what users see on first load
- Check universal state - Confirm your
*.svelte.tsstores work on the server - Prevent hydration mismatches - Catch differences between server and client rendering
- Performance validation - Ensure server rendering doesn’t break with complex state
- Content consistency - Verify that server-generated content is complete and accurate
Running SSR tests
These tests run with:
1pnpm run test:ssrThey’re fast because they don’t need browsers - just Node.js and Svelte’s server renderer!
Key SSR testing patterns
- Use
render()fromsvelte/server- Not the browser version - Test both
headandbody- Many components affect both - Check universal state - Ensure stores work server-side
- Validate calculated values - Reading time, dates, formatting
- Test conditional rendering - Different states should render different HTML
This completes the Client-Server Alignment Strategy - now you’re testing everywhere your code runs!
Testing server utilities
Now let’s test some server-side code. I’ll create a utility function for handling form data. First I need to create the directory:
1mkdir -p src/lib/serverThen create src/lib/server/form-utils.ts:
1export interface FormData {
2 name: string;
3 email: string;
4 message: string;
5}
6
7export function validate_form_data(data: FormData): {
8 valid: boolean;
9 errors: string[];
10} {
11 const errors: string[] = [];
12
13 if (!data.name || data.name.trim().length < 2) {
14 errors.push('Name must be at least 2 characters');
15 }
16
17 if (!data.email || !is_valid_email(data.email)) {
18 errors.push('Valid email is required');
19 }
20
21 if (!data.message || data.message.trim().length < 10) {
22 errors.push('Message must be at least 10 characters');
23 }
24
25 return {
26 valid: errors.length === 0,
27 errors,
28 };
29}
30
31function is_valid_email(email: string): boolean {
32 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
33 return emailRegex.test(email);
34}
35
36export function sanitize_input(input: string): string {
37 return input.trim().replace(/[<>]/g, '');
38}And the test for it, src/lib/server/form-utils.test.ts:
1import { describe, expect, it } from 'vitest';
2import { sanitize_input, validate_form_data } from './form-utils';
3
4describe('Form Utilities', () => {
5 describe('validate_form_data', () => {
6 it('validates correct form data', () => {
7 const valid_data = {
8 name: 'John Doe',
9 email: '[email protected]',
10 message: 'This is a valid message with enough characters',
11 };
12
13 const result = validate_form_data(valid_data);
14 expect(result.valid).toBe(true);
15 expect(result.errors).toHaveLength(0);
16 });
17
18 it.skip('catches validation errors', () => {
19 // Pattern: Test validation logic with invalid data
20 // Pass invalid form data, verify errors are returned
21 });
22
23 it.skip('handles empty data', () => {
24 // Pattern: Test edge case with empty/missing data
25 // Pass empty form data, verify appropriate errors
26 });
27 });
28
29 describe('sanitize_input', () => {
30 it('removes dangerous characters', () => {
31 const input = '<script>alert("xss")</script>Normal text';
32 const sanitized = sanitize_input(input);
33 expect(sanitized).toBe('scriptalert("xss")/scriptNormal text');
34 });
35
36 it.skip('trims whitespace', () => {
37 // Pattern: Test string processing utility
38 // Pass string with whitespace, verify trimmed result
39 });
40 });
41});These run with pnpm run test:server - pure Node.js testing for
server logic!
A contact form bringing it all together
Let me create a contact form that uses both the button component and the server utilities. First I’ll create the directory:
1mkdir -p src/routes/contactThen create src/routes/contact/+page.svelte:
1<script lang="ts">
2 import { Button } from '$lib/components';
3 import { enhance } from '$app/forms';
4
5 let { form } = $props();
6 let loading = $state(false);
7</script>
8
9<div class="mx-auto max-w-2xl p-6">
10 <h1 class="mb-6 text-3xl font-bold">Contact Me</h1>
11
12 {#if form?.success}
13 <div class="mb-6 alert alert-success">
14 Thanks for your message! I'll get back to you soon.
15 </div>
16 {/if}
17
18 {#if form?.errors && form.errors.length > 0}
19 <div class="mb-6 alert alert-error">
20 <ul>
21 {#each form.errors as error}
22 <li>{error}</li>
23 {/each}
24 </ul>
25 </div>
26 {/if}
27
28 <form
29 method="POST"
30 use:enhance={() => {
31 loading = true;
32 return async ({ update }) => {
33 loading = false;
34 await update();
35 };
36 }}
37 class="space-y-4"
38 >
39 <div class="form-control">
40 <label for="name" class="label">
41 <span class="label-text">Name</span>
42 </label>
43 <input
44 id="name"
45 name="name"
46 type="text"
47 required
48 class="input-bordered input w-full"
49 value={form?.data?.name ?? ''}
50 />
51 </div>
52
53 <div class="form-control">
54 <label for="email" class="label">
55 <span class="label-text">Email</span>
56 </label>
57 <input
58 id="email"
59 name="email"
60 type="email"
61 required
62 class="input-bordered input w-full"
63 value={form?.data?.email ?? ''}
64 />
65 </div>
66
67 <div class="form-control">
68 <label for="message" class="label">
69 <span class="label-text">Message</span>
70 </label>
71 <textarea
72 id="message"
73 name="message"
74 required
75 rows="4"
76 class="textarea-bordered textarea w-full"
77 value={form?.data?.message ?? ''}></textarea>
78 </div>
79
80 <Button type="submit" {loading} class_names="w-full">
81 {#snippet children()}
82 Send Message
83 {/snippet}
84 </Button>
85 </form>
86</div>And the form action in src/routes/contact/+page.server.ts:
1import { validate_form_data } from '$lib/server/form-utils';
2import { fail } from '@sveltejs/kit';
3import type { Actions } from './$types';
4
5export const actions: Actions = {
6 default: async ({ request }) => {
7 const data = await request.formData();
8
9 const form_data = {
10 name: data.get('name') as string,
11 email: data.get('email') as string,
12 message: data.get('message') as string,
13 };
14
15 const validation = validate_form_data(form_data);
16
17 if (!validation.valid) {
18 return fail(400, {
19 errors: validation.errors,
20 data: form_data,
21 });
22 }
23
24 // Here you'd normally send the email or save to database
25 console.log('Form submitted:', form_data);
26
27 return {
28 success: true,
29 };
30 },
31};Testing the contact form
Now for the fun part! Testing the whole form interaction. I’ll create src/routes/contact/+page.svelte.test.ts:
1import { page } from '@vitest/browser/context';
2import { describe, expect, it } from 'vitest';
3import { render } from 'vitest-browser-svelte';
4import ContactPage from './+page.svelte';
5
6describe('Contact Page', () => {
7 it('renders the contact form', async () => {
8 render(ContactPage, {
9 form: null,
10 });
11
12 await expect
13 .element(page.getByRole('heading', { level: 1 }))
14 .toHaveTextContent('Contact Me');
15
16 await expect
17 .element(page.getByLabelText('Name'))
18 .toBeInTheDocument();
19 await expect
20 .element(page.getByLabelText('Email'))
21 .toBeInTheDocument();
22 await expect
23 .element(page.getByLabelText('Message'))
24 .toBeInTheDocument();
25 await expect
26 .element(page.getByRole('button', { name: 'Send Message' }))
27 .toBeInTheDocument();
28 });
29
30 it.skip('shows success message when form is successful', () => {
31 // Pattern: Test conditional rendering based on form state
32 // render(ContactPage, { form: { success: true } })
33 // await expect.element(page.getByText('Thanks for your message!')).toBeInTheDocument()
34 });
35
36 it.skip('shows validation errors', () => {
37 // Pattern: Test error display from form validation
38 // render(ContactPage, { form: { errors: [...] } })
39 // await expect.element(page.getByText('Name must be at least 2 characters')).toBeInTheDocument()
40 });
41
42 it.skip('preserves form data on validation errors', () => {
43 // Pattern: Test form data persistence after validation failure
44 // render(ContactPage, { form: { errors: [...], data: {...} } })
45 // await expect.element(page.getByDisplayValue('John')).toBeInTheDocument()
46 });
47});E2E testing with Playwright
Finally, let’s add some E2E tests to make sure everything works
together. Here’s tests/contact.spec.ts:
1import { expect, test } from '@playwright/test';
2
3test.describe('Contact Form E2E', () => {
4 test('successfully submits contact form', async ({ page }) => {
5 await page.goto('/contact');
6
7 // Fill out the form
8 await page.fill('[name="name"]', 'John Doe');
9 await page.fill('[name="email"]', '[email protected]');
10 await page.fill(
11 '[name="message"]',
12 'This is a test message with enough characters to pass validation',
13 );
14
15 // Submit the form
16 await page.click('button[type="submit"]');
17
18 // Check for success message
19 await expect(
20 page.getByText('Thanks for your message!'),
21 ).toBeVisible();
22 });
23
24 test.skip('shows validation errors for invalid data', () => {
25 // Pattern: E2E form validation testing
26 // Fill form with invalid data, submit, verify error messages appear
27 });
28
29 test.skip('shows loading state during form submission', () => {
30 // Pattern: E2E loading state testing
31 // Fill form, click submit, immediately check for loading indicator
32 });
33});Running all the tests
Now I can run each test suite individually:
1# Client-side component tests
2pnpm run test:client
3
4# Server-side utility tests
5pnpm run test:server
6
7# E2E tests
8pnpm run test:e2eOr all at once:
1pnpm run testThe beauty of this approach
This testing strategy gives me:
- Separation of concerns - Each test runs in its appropriate environment
- Real browser testing - No more mocking browser APIs for component tests
- Fast server tests - Pure Node.js testing for utilities
- Comprehensive coverage - E2E tests ensure everything works together
- Great DX - Clear error messages and debugging in real browsers
The Client-Server Alignment Strategy means I’m testing things where they actually run, leading to more reliable tests and fewer surprises in production!
Testing patterns to remember
- Use locators instead of manual DOM queries - they’re more reliable and wait automatically
- Await all assertions with
expect.element()in browser tests - No more
flushSync()needed for most cases - locators handle the waiting - Test user interactions in browser tests, test logic in server tests
- Use semantic queries like
getByRole()andgetByLabelText()for better accessibility testing
This approach has completely changed how I think about testing in SvelteKit. No more fighting with mocks or trying to simulate browser behavior - just test in the environment where your code actually runs!
Setting up CI/CD
Right, you’ve got your tests working locally, but what about CI? This is where things get interesting! I’m going to set up GitHub Actions with the same Client-Server Alignment Strategy.
I’ll create two separate workflows - one for unit tests (client + server) and one for E2E tests. This separation means if my E2E tests are flaky, my unit tests can still pass and vice versa.
Let’s create .github/workflows/unit-tests.yml:
1name: Unit Tests
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9jobs:
10 unit-tests:
11 name: Run unit tests
12 runs-on: ubuntu-latest
13 container:
14 # Using Playwright container for pre-installed browsers
15 image: mcr.microsoft.com/playwright:v1.52.0-noble
16 options: --user 1001
17
18 steps:
19 - name: Checkout
20 uses: actions/checkout@v4
21
22 - name: Setup pnpm
23 uses: pnpm/[email protected]
24
25 - name: Setup Node.js
26 uses: actions/setup-node@v4
27 with:
28 node-version: 20
29 cache: 'pnpm'
30
31 - name: Install dependencies
32 run: pnpm install
33
34 - name: Run client tests
35 run: pnpm run test:client
36
37 - name: Run server tests
38 run: pnpm run test:serverAnd .github/workflows/e2e-tests.yml:
1name: E2E Tests
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9jobs:
10 e2e-tests:
11 name: Run E2E tests
12 runs-on: ubuntu-latest
13
14 steps:
15 - name: Checkout
16 uses: actions/checkout@v4
17
18 - name: Setup pnpm
19 uses: pnpm/[email protected]
20
21 - name: Setup Node.js
22 uses: actions/setup-node@v4
23 with:
24 node-version: 20
25 cache: 'pnpm'
26
27 - name: Install dependencies
28 run: pnpm install
29
30 - name: Install Playwright browsers
31 run: pnpm exec playwright install --with-deps
32
33 - name: Build application
34 run: pnpm run build
35
36 - name: Run E2E tests
37 run: pnpm run test:e2e
38
39 - name: Upload test results
40 uses: actions/upload-artifact@v4
41 if: failure()
42 with:
43 name: playwright-report
44 path: playwright-report/Why separate workflows?
This separation is brilliant for a few reasons:
- Independent failures - E2E tests can be flaky, but that won’t block your unit tests
- Different requirements - Unit tests need Playwright containers, E2E tests need full app builds
- Faster feedback - Unit tests run faster, so you get quicker feedback on basic functionality
- Resource optimization - You can scale these differently based on your needs
The Playwright container advantage
Using mcr.microsoft.com/playwright:v1.52.0-noble for unit tests is a
game changer! No more waiting for browser downloads:
- Pre-installed browsers - Chromium is already there
- Optimized environment - Tuned specifically for browser testing
- Consistent versions - Same browser versions every time
- Faster CI runs - No download time means faster feedback
Environment variables for server tests
If your server tests need environment variables, add them to your workflow:
1- name: Run server tests
2 run: pnpm run test:server
3 env:
4 DATABASE_URL: ${{ secrets.DATABASE_URL }}
5 API_SECRET: ${{ secrets.API_SECRET }}Caching for speed
The cache: 'pnpm' in the Node.js setup step caches your node_modules, making subsequent runs much faster. Combined with the
Playwright container, you’re looking at seriously optimized CI times!
Troubleshooting CI issues
Browser tests fail with “No browsers found”: Make sure you’re
using the Playwright container for unit tests and running playwright install for E2E tests.
Permission errors in container: Always use --user 1001 in
container options to avoid permission issues.
Timeout issues: Browser tests can be slower in CI. Consider
increasing the testTimeout in your Vitest config for CI
environments.
This CI setup follows the same Client-Server Alignment Strategy as your local development - test where you run! Unit tests run in optimized containers, E2E tests run in full environments, and everything stays fast and reliable.
Alternative: Component-Internal Runes Testing
If you want to avoid the flushSync() requirement, consider testing
runes through component props instead of external universal state:
1// Component that accepts initial state as props
2test('counter with internal runes', async () => {
3 render(Counter, { initial_count: 5 });
4
5 const count_display = page.getByTestId('count');
6 await expect.element(count_display).toHaveTextContent('5');
7
8 const increment_button = page.getByRole('button', {
9 name: 'Increment',
10 });
11 await increment_button.click();
12
13 // No flushSync needed - component-internal reactivity works automatically
14 await expect.element(count_display).toHaveTextContent('6');
15});This pattern avoids the external state complexity while still testing runes thoroughly.
Now get out there and write some tests! Your future self (and your users) will thank you! 🚀
There's a reactions leaderboard you can check out too.
Sign up for the newsletter
Want to keep up to date with what I'm working on?
Join other developers and sign up for the newsletter.
I care about the protection of your data. Read the Privacy Policy for more info.