SvelteKit Testing: Vitest, Testing Library & Playwright (2026)
SvelteKit gives you fast builds and a clean full-stack model. It also ships with no testing setup at all. You add a route, it works in the browser, and you move on — until it breaks in a way that would have taken two minutes to catch with a test.
This is the testing setup that works for SvelteKit in 2026: Vitest for unit and component tests, @testing-library/svelte for behavior-focused component tests, and Playwright for E2E.
Vitest Setup for SvelteKit
SvelteKit uses Vite under the hood, which means Vitest integrates without friction. Install the testing dependencies:
npm install -D vitest @testing-library/svelte @testing-library/jest-dom \
@sveltejs/vite-plugin-svelte jsdomAdd a vitest.config.ts at 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 to extend expect matchers:
// src/test-setup.ts
import '@testing-library/jest-dom';Add test scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
}
}Run npm test — Vitest picks up any *.test.ts or *.spec.ts file. You're set.
Testing Svelte Components with @testing-library/svelte
@testing-library/svelte renders components into a real (jsdom) DOM and lets you query them the way users would — by text, role, and label. Avoid querying by class names or internal implementation details.
<!-- src/lib/Counter.svelte -->
<script lang="ts">
let count = $state(0);
function increment() {
count++;
}
function decrement() {
count--;
}
</script>
<div>
<button on:click={decrement} aria-label="Decrement">−</button>
<span data-testid="count">{count}</span>
<button on:click={increment} aria-label="Increment">+</button>
</div>// src/lib/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('renders with initial count of 0', () => {
render(Counter);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('increments count when + button is clicked', async () => {
render(Counter);
await fireEvent.click(screen.getByLabelText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('decrements count when − button is clicked', async () => {
render(Counter);
await fireEvent.click(screen.getByLabelText('Decrement'));
expect(screen.getByTestId('count')).toHaveTextContent('-1');
});
it('handles multiple clicks correctly', async () => {
render(Counter);
const inc = screen.getByLabelText('Increment');
await fireEvent.click(inc);
await fireEvent.click(inc);
await fireEvent.click(inc);
expect(screen.getByTestId('count')).toHaveTextContent('3');
});
});For components that accept props:
import { render } from '@testing-library/svelte';
import UserCard from './UserCard.svelte';
it('renders the user name passed as prop', () => {
render(UserCard, { props: { name: 'Ada Lovelace', role: 'admin' } });
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
expect(screen.getByText('admin')).toBeInTheDocument();
});Testing SvelteKit Load Functions
SvelteKit's load functions are server-side (or universal) functions that fetch data before a route renders. They're plain TypeScript — test them without rendering anything.
// src/routes/posts/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const response = await fetch(`/api/posts/${params.slug}`);
if (!response.ok) {
throw error(404, 'Post not found');
}
const post = await response.json();
return { post };
};// src/routes/posts/[slug]/page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from './+page.server';
describe('posts load function', () => {
it('returns post data when API responds successfully', async () => {
const mockPost = { id: 1, slug: 'hello-world', title: 'Hello World' };
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockPost,
});
const result = await load({
params: { slug: 'hello-world' },
fetch: mockFetch,
} as any);
expect(result.post).toEqual(mockPost);
expect(mockFetch).toHaveBeenCalledWith('/api/posts/hello-world');
});
it('throws a 404 error when the post does not exist', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
await expect(
load({ params: { slug: 'does-not-exist' }, fetch: mockFetch } as any)
).rejects.toMatchObject({ status: 404 });
});
it('calls the API with the correct slug from params', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ slug: 'specific-slug' }),
});
await load({ params: { slug: 'specific-slug' }, fetch: mockFetch } as any);
expect(mockFetch).toHaveBeenCalledWith('/api/posts/specific-slug');
});
});Load function tests are fast (no browser, no server startup) and catch the class of bugs that component tests miss: wrong API URLs, missing error handling, and broken destructuring of the response shape.
Testing SvelteKit Form Actions
Form actions handle POST requests from <form> elements. Test them directly by constructing a fake RequestEvent.
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email')?.toString().trim();
const message = data.get('message')?.toString().trim();
if (!email || !email.includes('@')) {
return fail(422, { error: 'Valid email is required', email });
}
if (!message || message.length < 10) {
return fail(422, { error: 'Message must be at least 10 characters' });
}
await sendContactEmail({ email, message });
throw redirect(303, '/contact/thanks');
},
};// src/routes/contact/page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { actions } from './+page.server';
vi.mock('$lib/email', () => ({
sendContactEmail: vi.fn().mockResolvedValue(undefined),
}));
function makeRequest(fields: Record<string, string>) {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
formData.append(key, value);
}
return { formData: async () => formData } as any;
}
describe('contact form action', () => {
it('returns a 422 when email is missing', async () => {
const result = await actions.default({
request: makeRequest({ message: 'This is a long enough message' }),
} as any);
expect(result?.status).toBe(422);
expect(result?.data?.error).toMatch(/email/i);
});
it('returns a 422 when email is invalid', async () => {
const result = await actions.default({
request: makeRequest({ email: 'not-an-email', message: 'Long enough message here' }),
} as any);
expect(result?.status).toBe(422);
});
it('returns a 422 when message is too short', async () => {
const result = await actions.default({
request: makeRequest({ email: 'user@example.com', message: 'Short' }),
} as any);
expect(result?.status).toBe(422);
expect(result?.data?.error).toMatch(/10 characters/i);
});
it('redirects to /contact/thanks on valid submission', async () => {
await expect(
actions.default({
request: makeRequest({
email: 'user@example.com',
message: 'This is a valid message that is long enough.',
}),
} as any)
).rejects.toMatchObject({ status: 303, location: '/contact/thanks' });
});
});Testing form actions this way verifies every validation branch and the success redirect without spinning up a dev server. Add these before writing validation logic — they fail, then you implement.
Playwright E2E for SvelteKit
Playwright tests the full application running in a real browser. SvelteKit ships with a @playwright/test integration you can opt into during npm create svelte@latest, or add manually:
npm install -D @playwright/test
npx playwright install chromium// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:4173',
},
});// e2e/contact.test.ts
import { test, expect } from '@playwright/test';
test.describe('contact form', () => {
test('shows validation error for missing email', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="message"]', 'This is a valid message with enough characters.');
await page.click('[type="submit"]');
await expect(page.getByText(/valid email is required/i)).toBeVisible();
});
test('submits successfully and redirects to thank-you page', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="message"]', 'This is a valid message with enough characters.');
await page.click('[type="submit"]');
await expect(page).toHaveURL('/contact/thanks');
await expect(page.getByText(/thank you/i)).toBeVisible();
});
test('preserves email field value after failed validation', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="message"]', 'Short');
await page.click('[type="submit"]');
await expect(page.locator('[name="email"]')).toHaveValue('user@example.com');
});
});Run E2E tests with npx playwright test. Add them to CI on pull requests against staging, not every commit — they take 20–60 seconds per test file.
What Your Tests Still Miss
Your Vitest and Playwright tests cover code paths you wrote. They don't cover what happens in production:
- A third-party API your load function calls goes down or changes its response shape
- A deployment introduces a build error that breaks SSR on specific routes
- A race condition in form submission causes duplicate requests under real network latency
- A user on an older browser hits a JS compatibility issue your jsdom tests can't reproduce
- Your site loads slowly after a dependency update and users bounce before the page renders
These failures don't show up in unit tests because they're environmental. You need something running against the real production URL on a schedule.
Monitoring SvelteKit Apps in Production with HelpMeTest
Install HelpMeTest:
curl -fsSL https://helpmetest.com/install | bashWrite tests in plain English that run against your deployed app:
Go to https://yourapp.com/contact
Fill in "email" with "test@example.com"
Fill in "message" with "This is a test message for the contact form"
Click the submit button
Verify the page URL contains "/contact/thanks"
Verify the text "Thank you" is visibleHelpMeTest runs these on a cron schedule and alerts you when they fail. If your SvelteKit app breaks in production — a load function starts returning 500s, a route stops rendering, a form silently stops working — you know within minutes instead of finding out from a user ticket.
Free tier: 10 tests, unlimited health checks. Pro: $100/month for full monitoring coverage across all your routes.
Your Vitest tests prove the code is correct. HelpMeTest proves the deployed app is working. You need both.
Start at helpmetest.com — free tier, no credit card.