Testing SvelteKit API Routes and Server Load Functions (2026)
SvelteKit API routes (+server.ts) and server load functions (+page.server.ts) contain your application's backend logic. They handle request validation, database queries, authentication checks, and error responses. Testing them directly — without starting a server — is fast, reliable, and catches the class of bugs that frontend tests completely miss.
The Two Types of Server-Side Code in SvelteKit
+server.ts files define API routes that handle HTTP requests directly:
// src/routes/api/posts/+server.ts
export async function GET({ url }) { ... }
export async function POST({ request }) { ... }+page.server.ts files define load functions and form actions for pages:
// src/routes/blog/+page.server.ts
export const load = async ({ fetch, params }) => { ... }
export const actions = { default: async ({ request }) => { ... } }Both are plain TypeScript. Both can be imported and called in tests.
Testing API Route Handlers
SvelteKit request handlers receive a RequestEvent object. In tests, construct it manually.
// src/routes/api/posts/+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 page = Number(url.searchParams.get('page') ?? '1');
const limit = Number(url.searchParams.get('limit') ?? '10');
if (page < 1 || limit < 1 || limit > 100) {
throw error(400, 'Invalid pagination parameters');
}
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return json({ posts, page, limit });
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Authentication required');
}
const body = await request.json();
if (!body.title?.trim()) {
throw error(422, 'Title is required');
}
if (!body.content?.trim()) {
throw error(422, 'Content is required');
}
const post = await db.posts.create({
data: {
title: body.title.trim(),
content: body.content.trim(),
authorId: locals.user.id,
},
});
return json(post, { status: 201 });
};Test the handlers by calling them directly:
// src/routes/api/posts/server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET, POST } from './+server';
vi.mock('$lib/server/db', () => ({
db: {
posts: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
import { db } from '$lib/server/db';
function makeUrl(params: Record<string, string> = {}) {
const url = new URL('http://localhost/api/posts');
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url;
}
function makeRequest(body: unknown) {
return new Request('http://localhost/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('GET /api/posts', () => {
beforeEach(() => {
vi.mocked(db.posts.findMany).mockResolvedValue([
{ id: 1, title: 'First Post', content: 'Content', createdAt: new Date() },
]);
});
it('returns posts with default pagination', async () => {
const response = await GET({ url: makeUrl() } as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.posts).toHaveLength(1);
expect(data.page).toBe(1);
expect(data.limit).toBe(10);
});
it('passes pagination params to the database query', async () => {
await GET({ url: makeUrl({ page: '2', limit: '5' }) } as any);
expect(db.posts.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 5, take: 5 })
);
});
it('rejects invalid page parameter', async () => {
await expect(
GET({ url: makeUrl({ page: '0' }) } as any)
).rejects.toMatchObject({ status: 400 });
});
it('rejects limit over 100', async () => {
await expect(
GET({ url: makeUrl({ limit: '101' }) } as any)
).rejects.toMatchObject({ status: 400 });
});
});
describe('POST /api/posts', () => {
it('returns 401 when user is not authenticated', async () => {
await expect(
POST({
request: makeRequest({ title: 'Test', content: 'Body' }),
locals: {},
} as any)
).rejects.toMatchObject({ status: 401 });
});
it('returns 422 when title is missing', async () => {
await expect(
POST({
request: makeRequest({ content: 'Body text' }),
locals: { user: { id: 'user-1' } },
} as any)
).rejects.toMatchObject({ status: 422 });
});
it('returns 422 when content is missing', async () => {
await expect(
POST({
request: makeRequest({ title: 'A Title' }),
locals: { user: { id: 'user-1' } },
} as any)
).rejects.toMatchObject({ status: 422 });
});
it('creates post and returns 201 for valid request', async () => {
const newPost = { id: 2, title: 'New Post', content: 'Body text', authorId: 'user-1' };
vi.mocked(db.posts.create).mockResolvedValue(newPost as any);
const response = await POST({
request: makeRequest({ title: 'New Post', content: 'Body text' }),
locals: { user: { id: 'user-1' } },
} as any);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.title).toBe('New Post');
});
it('passes authorId from authenticated user', async () => {
vi.mocked(db.posts.create).mockResolvedValue({ id: 3 } as any);
await POST({
request: makeRequest({ title: 'Post', content: 'Content body' }),
locals: { user: { id: 'specific-user-id' } },
} as any);
expect(db.posts.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ authorId: 'specific-user-id' }),
})
);
});
});Testing Load Functions
Load functions fetch data before a route renders. Test them the same way — call them directly with a mock RequestEvent.
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true },
});
if (!post) {
throw error(404, `Post "${params.slug}" not found`);
}
const relatedPosts = await db.posts.findMany({
where: {
tags: { some: { id: { in: post.tags.map((t) => t.id) } } },
NOT: { id: post.id },
},
take: 3,
});
return {
post,
relatedPosts,
isAuthor: locals.user?.id === post.authorId,
};
};// src/routes/blog/[slug]/page.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { load } from './+page.server';
vi.mock('$lib/server/db', () => ({
db: {
posts: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
},
}));
import { db } from '$lib/server/db';
const mockPost = {
id: 1,
slug: 'hello-world',
title: 'Hello World',
content: 'Post content',
authorId: 'author-1',
author: { id: 'author-1', name: 'Ada Lovelace' },
tags: [{ id: 'tag-1', name: 'SvelteKit' }],
};
describe('blog post load function', () => {
beforeEach(() => {
vi.mocked(db.posts.findUnique).mockResolvedValue(mockPost as any);
vi.mocked(db.posts.findMany).mockResolvedValue([]);
});
it('returns post data when slug matches', async () => {
const result = await load({
params: { slug: 'hello-world' },
locals: {},
} as any);
expect(result.post).toEqual(mockPost);
});
it('throws 404 when post does not exist', async () => {
vi.mocked(db.posts.findUnique).mockResolvedValue(null);
await expect(
load({ params: { slug: 'nonexistent' }, locals: {} } as any)
).rejects.toMatchObject({ status: 404 });
});
it('sets isAuthor to true when user is the post author', async () => {
const result = await load({
params: { slug: 'hello-world' },
locals: { user: { id: 'author-1' } },
} as any);
expect(result.isAuthor).toBe(true);
});
it('sets isAuthor to false for other users', async () => {
const result = await load({
params: { slug: 'hello-world' },
locals: { user: { id: 'other-user' } },
} as any);
expect(result.isAuthor).toBe(false);
});
it('sets isAuthor to false for unauthenticated visitors', async () => {
const result = await load({
params: { slug: 'hello-world' },
locals: {},
} as any);
expect(result.isAuthor).toBe(false);
});
it('queries related posts by tag IDs', async () => {
await load({ params: { slug: 'hello-world' }, locals: {} } as any);
expect(db.posts.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
tags: { some: { id: { in: ['tag-1'] } } },
}),
})
);
});
});Testing with a Real Database (Optional)
For tests that need to verify actual database behavior — complex queries, transactions, constraints — use a test database instead of mocking.
Create a test database setup:
// src/test-setup.ts
import { beforeAll, afterAll, beforeEach } from 'vitest';
import { db } from '$lib/server/db';
beforeAll(async () => {
// Run migrations against test database
// DATABASE_URL should point to a test DB when running tests
await db.$executeRaw`DELETE FROM posts`;
await db.$executeRaw`DELETE FROM users`;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
// Reset state between tests
await db.posts.deleteMany();
});Set the test database via environment:
DATABASE_URL="postgresql://localhost/myapp_test" npm <span class="hljs-built_in">testIntegration tests with a real database catch query bugs that mocks miss: wrong column names, missing indexes, constraint violations, and N+1 queries.
Testing SvelteKit Hooks
Hooks in src/hooks.server.ts run before every request. They handle authentication, logging, and request modification. Test them by calling the handle function directly.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { db } from '$lib/server/db';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
const session = await db.sessions.findUnique({ where: { token } });
if (session) {
event.locals.user = session.user;
}
}
return resolve(event);
};// src/hooks.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { handle } from './hooks.server';
vi.mock('$lib/server/db', () => ({
db: {
sessions: {
findUnique: vi.fn(),
},
},
}));
import { db } from '$lib/server/db';
function makeEvent(cookies: Record<string, string> = {}) {
return {
cookies: { get: (name: string) => cookies[name] },
locals: {},
};
}
const mockResolve = vi.fn().mockResolvedValue(new Response('OK'));
describe('auth hook', () => {
it('sets user in locals when valid session exists', async () => {
const mockUser = { id: 'user-1', email: 'user@test.com' };
vi.mocked(db.sessions.findUnique).mockResolvedValue({ user: mockUser } as any);
const event = makeEvent({ session: 'valid-token' }) as any;
await handle({ event, resolve: mockResolve });
expect(event.locals.user).toEqual(mockUser);
});
it('does not set user when session token is missing', async () => {
const event = makeEvent() as any;
await handle({ event, resolve: mockResolve });
expect(event.locals.user).toBeUndefined();
expect(db.sessions.findUnique).not.toHaveBeenCalled();
});
it('does not set user when session is invalid', async () => {
vi.mocked(db.sessions.findUnique).mockResolvedValue(null);
const event = makeEvent({ session: 'invalid-token' }) as any;
await handle({ event, resolve: mockResolve });
expect(event.locals.user).toBeUndefined();
});
});What to Test and What to Skip
Worth testing:
- Validation logic (all invalid inputs, boundary values)
- Authentication and authorization checks
- Database query logic with meaningful mock assertions
- Error handling paths that return specific status codes
- Computed values returned to the page
Skip:
- Simple pass-through routes with no logic
- Database queries so simple they just restate the ORM API
- Infrastructure-level behavior (connection pooling, DB failover)
Monitoring Deployed API Routes with HelpMeTest
Unit tests prove your handlers work in isolation. After deployment, you need to verify they work end-to-end under real conditions.
HelpMeTest can monitor your SvelteKit API routes continuously:
Go to https://myapp.com/api/posts
Verify the response contains a "posts" array
Verify the response status is 200When an API route breaks in production — a database query fails, an environment variable is missing, a dependency upgrade changes behavior — HelpMeTest catches it and alerts you immediately.
Free tier: 10 tests, unlimited health checks.
Pro: $100/month — unlimited tests, 24/7 monitoring with 5-minute check intervals.
Start free at helpmetest.com — no credit card required.