Testing SvelteKit API Routes and Server Load Functions (2026)

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">test

Integration 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 200

When 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.

Read more