Testing SvelteKit Routes and Form Actions: A Practical Guide
SvelteKit's file-based routing exposes three testable units: load functions (server and universal), actions (form handlers), and API route handlers (+server.ts). Because these are plain async functions, you can test them with Vitest without spinning up a full server.
Setup
npm install -D vitest @sveltejs/kit jsdomIn vite.config.ts:
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
environment: 'node', // Use 'jsdom' for component tests
},
});Testing Load Functions
Universal Load Function
// src/routes/products/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch, params }) => {
const res = await fetch(`/api/products/${params.id}`);
if (!res.ok) {
throw new Error('Failed to load product');
}
return { product: await res.json() };
};// src/routes/products/+page.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from './+page';
describe('product page load', () => {
it('returns product data on success', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Widget', price: 9.99 }),
});
const result = await load({
fetch: mockFetch,
params: { id: '1' },
} as any);
expect(result.product).toEqual({ id: '1', name: 'Widget', price: 9.99 });
expect(mockFetch).toHaveBeenCalledWith('/api/products/1');
});
it('throws on failed fetch', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
await expect(
load({ fetch: mockFetch, params: { id: '999' } } as any)
).rejects.toThrow('Failed to load product');
});
});Server Load Function
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const metrics = await db.getMetrics(locals.user.id);
return { metrics };
};// src/routes/dashboard/+page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from './+page.server';
vi.mock('$lib/server/db', () => ({
db: {
getMetrics: vi.fn(),
},
}));
import { db } from '$lib/server/db';
describe('dashboard server load', () => {
it('redirects unauthenticated users', async () => {
await expect(
load({ locals: { user: null } } as any)
).rejects.toMatchObject({ status: 303, location: '/login' });
});
it('returns metrics for authenticated users', async () => {
vi.mocked(db.getMetrics).mockResolvedValue({ visits: 100, revenue: 500 });
const result = await load({
locals: { user: { id: 'user-1' } },
} as any);
expect(result.metrics).toEqual({ visits: 100, revenue: 500 });
expect(db.getMetrics).toHaveBeenCalledWith('user-1');
});
});Testing Form Actions
// src/routes/login/+page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
if (!email || !password) {
return fail(400, { error: 'Email and password are required' });
}
const session = await auth.login(email, password);
if (!session) {
return fail(401, { error: 'Invalid credentials' });
}
cookies.set('session', session.token, { path: '/' });
throw redirect(303, '/dashboard');
},
};// src/routes/login/+page.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { actions } from './+page.server';
vi.mock('$lib/server/auth', () => ({
auth: {
login: vi.fn(),
},
}));
import { auth } from '$lib/server/auth';
function makeRequest(formData: Record<string, string>) {
return {
request: {
formData: async () => new Map(Object.entries(formData)),
},
cookies: {
set: vi.fn(),
},
};
}
describe('login action', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when email is missing', async () => {
const result = await actions.default(
makeRequest({ password: 'secret' }) as any
);
expect(result).toMatchObject({ status: 400, data: { error: expect.stringContaining('required') } });
});
it('returns 401 on invalid credentials', async () => {
vi.mocked(auth.login).mockResolvedValue(null);
const result = await actions.default(
makeRequest({ email: 'a@b.com', password: 'wrong' }) as any
);
expect(result).toMatchObject({ status: 401 });
});
it('sets session cookie and redirects on success', async () => {
vi.mocked(auth.login).mockResolvedValue({ token: 'abc123' });
const { cookies } = makeRequest({ email: 'a@b.com', password: 'correct' });
await expect(
actions.default({ request: { formData: async () => new Map([['email', 'a@b.com'], ['password', 'correct']]) }, cookies } as any)
).rejects.toMatchObject({ status: 303, location: '/dashboard' });
expect(cookies.set).toHaveBeenCalledWith('session', 'abc123', { path: '/' });
});
});Testing Multiple Named Actions
// src/routes/profile/+page.server.ts
export const actions: Actions = {
updateName: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name')?.toString();
if (!name) return fail(400, { error: 'Name required' });
await db.updateUser(locals.user.id, { name });
return { success: true };
},
deleteAccount: async ({ locals, cookies }) => {
await db.deleteUser(locals.user.id);
cookies.delete('session', { path: '/' });
throw redirect(303, '/');
},
};describe('profile actions', () => {
it('updateName succeeds with valid name', async () => {
vi.mocked(db.updateUser).mockResolvedValue(undefined);
const result = await actions.updateName({
request: { formData: async () => new Map([['name', 'Alice']]) },
locals: { user: { id: '1' } },
} as any);
expect(result).toEqual({ success: true });
});
it('deleteAccount clears session and redirects', async () => {
vi.mocked(db.deleteUser).mockResolvedValue(undefined);
const cookies = { delete: vi.fn() };
await expect(
actions.deleteAccount({ locals: { user: { id: '1' } }, cookies } as any)
).rejects.toMatchObject({ status: 303 });
expect(cookies.delete).toHaveBeenCalledWith('session', { path: '/' });
});
});Testing API Routes
// src/routes/api/products/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const category = url.searchParams.get('category');
const products = await db.getProducts({ category: category ?? undefined });
return json(products);
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user?.isAdmin) {
throw error(403, 'Forbidden');
}
const body = await request.json();
const product = await db.createProduct(body);
return json(product, { status: 201 });
};import { describe, it, expect, vi } from 'vitest';
import { GET, POST } from './+server';
vi.mock('$lib/server/db', () => ({
db: {
getProducts: vi.fn(),
createProduct: vi.fn(),
},
}));
import { db } from '$lib/server/db';
describe('products API', () => {
it('GET returns all products', async () => {
vi.mocked(db.getProducts).mockResolvedValue([{ id: '1', name: 'Widget' }]);
const url = new URL('http://localhost/api/products');
const response = await GET({ url } as any);
const data = await response.json();
expect(data).toHaveLength(1);
expect(db.getProducts).toHaveBeenCalledWith({ category: undefined });
});
it('GET filters by category', async () => {
vi.mocked(db.getProducts).mockResolvedValue([]);
const url = new URL('http://localhost/api/products?category=electronics');
await GET({ url } as any);
expect(db.getProducts).toHaveBeenCalledWith({ category: 'electronics' });
});
it('POST returns 403 for non-admin', async () => {
await expect(
POST({ locals: { user: { isAdmin: false } }, request: { json: async () => ({}) } } as any)
).rejects.toMatchObject({ status: 403 });
});
it('POST creates product for admin', async () => {
vi.mocked(db.createProduct).mockResolvedValue({ id: 'new-1', name: 'Gadget' });
const response = await POST({
locals: { user: { isAdmin: true } },
request: { json: async () => ({ name: 'Gadget', price: 29.99 }) },
} as any);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.id).toBe('new-1');
});
});Testing Error Boundaries
// src/routes/+error.svelte — test the error page component
import { render, screen } from '@testing-library/svelte';
import ErrorPage from './+error.svelte';
import { page } from '$app/stores';
vi.mock('$app/stores', () => ({
page: {
subscribe: vi.fn((fn) => {
fn({ error: { message: 'Not found' }, status: 404 });
return () => {};
}),
},
}));
describe('+error page', () => {
it('displays error message and status', () => {
render(ErrorPage);
expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getByText('Not found')).toBeInTheDocument();
});
});Tips for SvelteKit Route Testing
- Load functions are plain async functions — test them directly without mounting components
- Form actions return
fail()objects or throwredirect()— test both paths - Mock
$app/storesfor components that consumepage,navigating, orupdatedstores - Use
error()from@sveltejs/kitin server code; test by asserting the thrown status - Server vs universal load — server load runs only on the server side, universal load runs on both; test accordingly
- Type casting with
as anyavoids mock exhaustiveness issues with the generated$types
Testing routes at the function level is faster than integration tests and catches the most common bugs — missing auth checks, broken data transformations, and unhandled error cases.