SvelteKit Testing Guide: Unit, Integration & E2E (2026)
SvelteKit ships without a testing setup. You create a project with npm create svelte@latest, pick a template, and get a working app — but zero tests. This guide covers everything you need: unit tests for utilities, component tests for Svelte components, integration tests for load functions and API routes, and E2E tests with Playwright.
The SvelteKit Testing Stack
Three tools cover all testing layers in a SvelteKit project:
| Layer | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Pure functions, utilities, stores |
| Component | @testing-library/svelte | Svelte component behavior |
| E2E | Playwright | Full app in a real browser |
Vitest is the natural choice because SvelteKit runs on Vite — Vitest reuses the same Vite config with no extra build setup. Testing Library gives you a user-focused API for component tests. Playwright is the standard for SvelteKit E2E because it's fast, reliable, and supported by the SvelteKit team.
Installing Vitest
Install the required packages:
npm install -D vitest @testing-library/svelte @testing-library/jest-dom \
@sveltejs/vite-plugin-svelte jsdomCreate vitest.config.ts in the project root:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
});Create the setup file:
// src/test-setup.ts
import '@testing-library/jest-dom';Add scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Run npm test. Vitest finds any *.test.ts or *.spec.ts file automatically.
Unit Tests for Utility Functions
Start with pure functions — they're the easiest to test and give you the most confidence per line of test code.
// src/lib/format.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}// src/lib/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, truncate, slugify } from './format';
describe('formatDate', () => {
it('formats a date as Month Day, Year', () => {
const date = new Date('2026-01-15');
expect(formatDate(date)).toBe('January 15, 2026');
});
});
describe('truncate', () => {
it('returns the string unchanged when within max length', () => {
expect(truncate('Hello', 10)).toBe('Hello');
});
it('truncates and appends ellipsis when too long', () => {
expect(truncate('Hello World', 8)).toBe('Hello...');
});
it('handles exact length boundary', () => {
expect(truncate('Hello', 5)).toBe('Hello');
});
});
describe('slugify', () => {
it('converts spaces to hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('removes special characters', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
});
it('collapses multiple separators', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('strips leading and trailing hyphens', () => {
expect(slugify(' hello ')).toBe('hello');
});
});Unit tests like these run in milliseconds, have no dependencies, and will never give you a false positive.
Component Tests with @testing-library/svelte
Testing Library renders your component into a jsdom DOM and lets you query it the same way a user would interact with it — by visible text, ARIA roles, and labels. Avoid querying by CSS class or internal implementation details.
<!-- src/lib/SearchInput.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let placeholder = 'Search...';
export let value = '';
const dispatch = createEventDispatcher();
function handleInput(e: Event) {
value = (e.target as HTMLInputElement).value;
dispatch('search', { query: value });
}
function handleClear() {
value = '';
dispatch('search', { query: '' });
}
</script>
<div class="search-input">
<input
type="search"
{placeholder}
bind:value
on:input={handleInput}
aria-label="Search"
/>
{#if value}
<button on:click={handleClear} aria-label="Clear search">×</button>
{/if}
</div>// src/lib/SearchInput.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import SearchInput from './SearchInput.svelte';
describe('SearchInput', () => {
it('renders with the provided placeholder', () => {
render(SearchInput, { props: { placeholder: 'Find products...' } });
expect(screen.getByPlaceholderText('Find products...')).toBeInTheDocument();
});
it('shows clear button only when input has value', async () => {
render(SearchInput);
expect(screen.queryByLabelText('Clear search')).not.toBeInTheDocument();
await fireEvent.input(screen.getByLabelText('Search'), {
target: { value: 'hello' },
});
expect(screen.getByLabelText('Clear search')).toBeInTheDocument();
});
it('clears input when clear button is clicked', async () => {
render(SearchInput, { props: { value: 'existing query' } });
await fireEvent.click(screen.getByLabelText('Clear search'));
expect(screen.getByLabelText('Search')).toHaveValue('');
});
it('dispatches search event on input', async () => {
const { component } = render(SearchInput);
const handler = vi.fn();
component.$on('search', handler);
await fireEvent.input(screen.getByLabelText('Search'), {
target: { value: 'svelte' },
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ detail: { query: 'svelte' } })
);
});
});The Testing Library approach catches real bugs: missing ARIA labels, wrong event handling, conditional rendering that doesn't work. Tests that query by role and label also verify accessibility as a side effect.
Integration Tests for Load Functions
SvelteKit's load functions run before route components render. They're plain TypeScript, which means you can test them directly — no browser, no server.
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const res = await fetch(`/api/posts/${params.slug}`);
if (res.status === 404) {
throw error(404, `Post "${params.slug}" not found`);
}
if (!res.ok) {
throw error(500, 'Failed to load post');
}
const post = await res.json();
return { post };
};// src/routes/blog/[slug]/page.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { load } from './+page.server';
describe('blog post load function', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
});
it('returns post data on successful response', async () => {
const post = { slug: 'hello', title: 'Hello World', content: 'Body text' };
mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => post });
const result = await load({
params: { slug: 'hello' },
fetch: mockFetch,
} as any);
expect(result.post).toEqual(post);
});
it('throws 404 when post is not found', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await expect(
load({ params: { slug: 'nonexistent' }, fetch: mockFetch } as any)
).rejects.toMatchObject({ status: 404 });
});
it('throws 500 on API error', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500 });
await expect(
load({ params: { slug: 'some-post' }, fetch: mockFetch } as any)
).rejects.toMatchObject({ status: 500 });
});
it('requests the correct API URL', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
await load({ params: { slug: 'my-article' }, fetch: mockFetch } as any);
expect(mockFetch).toHaveBeenCalledWith('/api/posts/my-article');
});
});Load function tests catch the bugs that component tests miss: wrong API endpoints, missing error handling, and incorrect data shapes passed to the component.
E2E Tests with Playwright
Playwright tests the full app in a real browser. Add it alongside Vitest — they serve different purposes.
npm install -D @playwright/test
npx playwright install chromiumConfigure Playwright to start the SvelteKit dev server:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
},
});Write tests in the e2e/ folder:
// e2e/blog.test.ts
import { test, expect } from '@playwright/test';
test.describe('Blog', () => {
test('navigates to a post from the blog index', async ({ page }) => {
await page.goto('/blog');
const firstPost = page.getByRole('article').first().getByRole('link');
const postTitle = await firstPost.textContent();
await firstPost.click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(postTitle!);
});
test('shows 404 page for nonexistent post', async ({ page }) => {
await page.goto('/blog/this-post-does-not-exist');
await expect(page).toHaveURL(/\/blog\/this-post-does-not-exist/);
await expect(page.getByText(/not found/i)).toBeVisible();
});
test('search returns relevant results', async ({ page }) => {
await page.goto('/blog');
await page.fill('[aria-label="Search"]', 'svelte');
const results = page.getByRole('article');
await expect(results.first()).toBeVisible();
const count = await results.count();
expect(count).toBeGreaterThan(0);
});
});Run Playwright with npx playwright test. Add --ui for the interactive test runner.
What the Tests Don't Cover
Unit, component, and E2E tests cover the code you wrote on your local machine. They don't cover what happens after you deploy:
- A third-party API your load function depends on changes its schema
- A route starts throwing 500s for a specific combination of URL params
- A form stops working after a dependency update changes event handling
- SSR fails on certain routes under production load
- Your hosting provider has an outage that takes down specific routes
These failures won't show up in any local test. You need monitoring that runs against the real deployment.
Production Monitoring with HelpMeTest
HelpMeTest runs your tests against the deployed app on a schedule. Write tests in plain English:
Go to https://myapp.com/blog
Verify a list of article headings is visible
Click the first article heading
Verify the page URL changed
Verify an H1 heading is visibleWhen your SvelteKit app breaks in production — a load function starts failing, a route returns a blank page, an API endpoint goes down — HelpMeTest catches it within minutes and sends you an alert.
Install the CLI:
curl -fsSL https://helpmetest.com/install | bashFree tier: 10 tests, unlimited health checks, 5-minute check intervals.
Pro: $100/month — unlimited tests, parallel execution, 24/7 monitoring across all routes.
Your Vitest tests prove the logic is correct. Playwright tests verify the app works end-to-end locally. HelpMeTest proves the deployed app keeps working in production. All three are necessary.
Start free at helpmetest.com — no credit card required.