Testing SvelteKit Routes and Form Actions: A Practical Guide

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 jsdom

In 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 throw redirect() — test both paths
  • Mock $app/stores for components that consume page, navigating, or updated stores
  • Use error() from @sveltejs/kit in 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 any avoids 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.

Read more

Start now free