Testing SvelteKit Form Actions and Superforms (2026)

Testing SvelteKit Form Actions and Superforms (2026)

SvelteKit form actions handle POST requests from <form> elements on the server. They validate input, interact with databases, send emails, and return structured responses. Testing them directly — without a running server — catches validation bugs, missing error handling, and incorrect redirects before they reach users.

How Form Actions Work

A +page.server.ts file exports an actions object. Each key is an action name — default for single-action pages, named keys for multiple actions.

// src/routes/register/+page.server.ts
export const actions = {
  default: async ({ request, locals }) => {
    const data = await request.formData();
    // validate, process, return or throw
  },
};

SvelteKit routes the form's POST request to the correct action, runs it, and either renders the page with the returned data or follows the redirect. In tests, you call the action function directly.

Setup

npm install -D vitest @testing-library/svelte jsdom
// 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,
  },
});

Testing a Registration Form Action

Start with a real example — a user registration form with email validation, password confirmation, and duplicate-email detection.

// src/routes/register/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db';
import { hashPassword } from '$lib/server/auth';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();

    const email = data.get('email')?.toString().trim().toLowerCase() ?? '';
    const password = data.get('password')?.toString() ?? '';
    const confirmPassword = data.get('confirmPassword')?.toString() ?? '';
    const name = data.get('name')?.toString().trim() ?? '';

    const errors: Record<string, string> = {};

    if (!name) errors.name = 'Name is required';

    if (!email) {
      errors.email = 'Email is required';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      errors.email = 'Enter a valid email address';
    }

    if (!password) {
      errors.password = 'Password is required';
    } else if (password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }

    if (password !== confirmPassword) {
      errors.confirmPassword = 'Passwords do not match';
    }

    if (Object.keys(errors).length > 0) {
      return fail(422, { errors, values: { email, name } });
    }

    const existingUser = await db.users.findUnique({ where: { email } });
    if (existingUser) {
      return fail(422, {
        errors: { email: 'An account with this email already exists' },
        values: { email, name },
      });
    }

    const hashedPassword = await hashPassword(password);
    await db.users.create({
      data: { email, name, password: hashedPassword },
    });

    throw redirect(303, '/register/success');
  },
};

Test every validation branch and the success path:

// src/routes/register/page.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { actions } from './+page.server';

vi.mock('$lib/server/db', () => ({
  db: {
    users: {
      findUnique: vi.fn(),
      create: vi.fn(),
    },
  },
}));

vi.mock('$lib/server/auth', () => ({
  hashPassword: vi.fn().mockResolvedValue('hashed-password'),
}));

import { db } from '$lib/server/db';

function makeRequest(fields: Record<string, string>) {
  const formData = new FormData();
  for (const [key, value] of Object.entries(fields)) {
    formData.append(key, value);
  }
  return new Request('http://localhost/register', {
    method: 'POST',
    body: formData,
  });
}

const validFields = {
  name: 'Ada Lovelace',
  email: 'ada@example.com',
  password: 'securepassword',
  confirmPassword: 'securepassword',
};

describe('registration action', () => {
  beforeEach(() => {
    vi.mocked(db.users.findUnique).mockResolvedValue(null);
    vi.mocked(db.users.create).mockResolvedValue({} as any);
  });

  it('returns 422 with name error when name is missing', async () => {
    const { name, ...noName } = validFields;
    const result = await actions.default({ request: makeRequest(noName) } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.name).toBe('Name is required');
  });

  it('returns 422 with email error when email is missing', async () => {
    const { email, ...noEmail } = validFields;
    const result = await actions.default({ request: makeRequest(noEmail) } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.email).toBe('Email is required');
  });

  it('returns 422 for invalid email format', async () => {
    const result = await actions.default({
      request: makeRequest({ ...validFields, email: 'not-an-email' }),
    } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.email).toMatch(/valid email/i);
  });

  it('returns 422 when password is too short', async () => {
    const result = await actions.default({
      request: makeRequest({ ...validFields, password: 'short', confirmPassword: 'short' }),
    } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.password).toMatch(/8 characters/);
  });

  it('returns 422 when passwords do not match', async () => {
    const result = await actions.default({
      request: makeRequest({ ...validFields, confirmPassword: 'different-password' }),
    } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.confirmPassword).toMatch(/do not match/i);
  });

  it('returns 422 when email is already registered', async () => {
    vi.mocked(db.users.findUnique).mockResolvedValue({ id: '1' } as any);

    const result = await actions.default({
      request: makeRequest(validFields),
    } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.errors?.email).toMatch(/already exists/i);
  });

  it('preserves email and name in error response', async () => {
    const { name: _n, ...noName } = validFields;
    const result = await actions.default({
      request: makeRequest({ ...noName, name: '' }),
    } as any);

    expect(result?.data?.values?.email).toBe(validFields.email);
  });

  it('redirects to /register/success on valid submission', async () => {
    await expect(
      actions.default({ request: makeRequest(validFields) } as any)
    ).rejects.toMatchObject({ status: 303, location: '/register/success' });
  });

  it('creates user with normalized lowercase email', async () => {
    await expect(
      actions.default({
        request: makeRequest({ ...validFields, email: 'ADA@EXAMPLE.COM' }),
      } as any)
    ).rejects.toMatchObject({ status: 303 });

    expect(db.users.create).toHaveBeenCalledWith(
      expect.objectContaining({
        data: expect.objectContaining({ email: 'ada@example.com' }),
      })
    );
  });

  it('does not create user when validation fails', async () => {
    await actions.default({
      request: makeRequest({ ...validFields, password: 'short', confirmPassword: 'short' }),
    } as any);

    expect(db.users.create).not.toHaveBeenCalled();
  });
});

Testing Multiple Named Actions

When a page has multiple actions, test each one independently.

// src/routes/settings/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db';

export const actions: Actions = {
  updateProfile: async ({ request, locals }) => {
    if (!locals.user) return fail(401, { error: 'Unauthorized' });

    const data = await request.formData();
    const name = data.get('name')?.toString().trim() ?? '';

    if (!name) return fail(422, { error: 'Name is required' });

    await db.users.update({
      where: { id: locals.user.id },
      data: { name },
    });

    return { success: true, action: 'profile' };
  },

  changePassword: async ({ request, locals }) => {
    if (!locals.user) return fail(401, { error: 'Unauthorized' });

    const data = await request.formData();
    const current = data.get('currentPassword')?.toString() ?? '';
    const newPass = data.get('newPassword')?.toString() ?? '';

    if (!current || !newPass) return fail(422, { error: 'All fields required' });
    if (newPass.length < 8) return fail(422, { error: 'Password must be at least 8 characters' });

    // verify current password, update...
    return { success: true, action: 'password' };
  },
};
// src/routes/settings/page.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { actions } from './+page.server';

vi.mock('$lib/server/db', () => ({
  db: { users: { update: vi.fn() } },
}));

import { db } from '$lib/server/db';

function makeRequest(fields: Record<string, string>) {
  const formData = new FormData();
  for (const [k, v] of Object.entries(fields)) formData.append(k, v);
  return new Request('http://localhost', { method: 'POST', body: formData });
}

const authLocals = { user: { id: 'user-1' } };

describe('updateProfile action', () => {
  beforeEach(() => vi.mocked(db.users.update).mockResolvedValue({} as any));

  it('returns 401 without auth', async () => {
    const r = await actions.updateProfile({ request: makeRequest({ name: 'Ada' }), locals: {} } as any);
    expect(r?.status).toBe(401);
  });

  it('returns 422 when name is empty', async () => {
    const r = await actions.updateProfile({
      request: makeRequest({ name: '' }),
      locals: authLocals,
    } as any);
    expect(r?.status).toBe(422);
  });

  it('updates user and returns success', async () => {
    const r = await actions.updateProfile({
      request: makeRequest({ name: 'Ada Lovelace' }),
      locals: authLocals,
    } as any);
    expect(r?.data?.success).toBe(true);
  });
});

describe('changePassword action', () => {
  it('returns 422 when new password is too short', async () => {
    const r = await actions.changePassword({
      request: makeRequest({ currentPassword: 'oldpass', newPassword: 'short' }),
      locals: authLocals,
    } as any);
    expect(r?.status).toBe(422);
    expect(r?.data?.error).toMatch(/8 characters/);
  });
});

Testing Superforms

Superforms is a popular SvelteKit form library that handles validation (Zod, Yup, Valibot), loading states, and progressive enhancement. Test the server-side action the same way.

// src/routes/contact/+page.server.ts
import { superValidate, message } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import type { Actions, PageServerLoad } from './$types';

const schema = z.object({
  email: z.string().email('Valid email required'),
  subject: z.string().min(1, 'Subject is required').max(100),
  body: z.string().min(10, 'Message must be at least 10 characters'),
});

export const load: PageServerLoad = async () => {
  return { form: await superValidate(zod(schema)) };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(schema));

    if (!form.valid) {
      return message(form, 'Fix the errors below', { status: 422 });
    }

    await sendEmail(form.data);

    return message(form, 'Message sent!');
  },
};
// src/routes/contact/page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { actions } from './+page.server';

vi.mock('$lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));

function makeRequest(fields: Record<string, string>) {
  const formData = new FormData();
  for (const [k, v] of Object.entries(fields)) formData.append(k, v);
  return new Request('http://localhost/contact', { method: 'POST', body: formData });
}

const validData = {
  email: 'user@example.com',
  subject: 'Hello there',
  body: 'This is a complete message with enough characters.',
};

describe('contact action (Superforms)', () => {
  it('returns 422 for invalid email', async () => {
    const r = await actions.default({
      request: makeRequest({ ...validData, email: 'not-an-email' }),
    } as any);
    expect(r?.status).toBe(422);
  });

  it('returns 422 when body is too short', async () => {
    const r = await actions.default({
      request: makeRequest({ ...validData, body: 'Short' }),
    } as any);
    expect(r?.status).toBe(422);
  });

  it('returns success message on valid submission', async () => {
    const r = await actions.default({
      request: makeRequest(validData),
    } as any);
    expect(r?.data?.message).toBe('Message sent!');
  });
});

What's Worth Testing

Form actions are business logic. Every branch matters:

  • All invalid inputs and their specific error messages
  • The boundary between valid and invalid (e.g., exactly 8 characters vs 7)
  • Authentication guards (unauthenticated access returns 401)
  • Duplicate data detection (duplicate email, existing record)
  • Success redirects and return values
  • That side effects (database writes, emails) are called with the correct arguments
  • That side effects are NOT called when validation fails

Don't test the validation library itself — test that your action uses it correctly.

Production Monitoring with HelpMeTest

Unit tests verify your action logic in isolation. After deployment, you need to know the form actually works end-to-end.

HelpMeTest runs plain-English tests against your live app:

Go to https://myapp.com/register
Fill in "name" with "Test User"
Fill in "email" with "test@example.com"  
Fill in "password" with "testpassword"
Fill in "confirmPassword" with "testpassword"
Click the Register button
Verify the URL contains "/register/success"

When your registration form breaks in production — a database migration changes a column name, an environment variable is missing, a dependency upgrade breaks form parsing — HelpMeTest catches it within minutes.

Free tier: 10 tests, 5-minute monitoring intervals.
Pro: $100/month
— unlimited tests, parallel execution, 24/7 monitoring.


Start free at helpmetest.com — no credit card required.

Read more