SvelteKit Testing: Vitest, Testing Library & Playwright (2026)

SvelteKit Testing: Vitest, Testing Library & Playwright (2026)

SvelteKit gives you fast builds and a clean full-stack model. It also ships with no testing setup at all. You add a route, it works in the browser, and you move on — until it breaks in a way that would have taken two minutes to catch with a test.

This is the testing setup that works for SvelteKit in 2026: Vitest for unit and component tests, @testing-library/svelte for behavior-focused component tests, and Playwright for E2E.

Vitest Setup for SvelteKit

SvelteKit uses Vite under the hood, which means Vitest integrates without friction. Install the testing dependencies:

npm install -D vitest @testing-library/svelte @testing-library/jest-dom \
  @sveltejs/vite-plugin-svelte jsdom

Add a vitest.config.ts at the project root:

// 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,
    setupFiles: ['./src/test-setup.ts'],
  },
});

Create the setup file to extend expect matchers:

// src/test-setup.ts
import '@testing-library/jest-dom';

Add test scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui"
  }
}

Run npm test — Vitest picks up any *.test.ts or *.spec.ts file. You're set.

Testing Svelte Components with @testing-library/svelte

@testing-library/svelte renders components into a real (jsdom) DOM and lets you query them the way users would — by text, role, and label. Avoid querying by class names or internal implementation details.

<!-- src/lib/Counter.svelte -->
<script lang="ts">
  let count = $state(0);

  function increment() {
    count++;
  }

  function decrement() {
    count--;
  }
</script>

<div>
  <button on:click={decrement} aria-label="Decrement">−</button>
  <span data-testid="count">{count}</span>
  <button on:click={increment} aria-label="Increment">+</button>
</div>
// src/lib/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';

describe('Counter', () => {
  it('renders with initial count of 0', () => {
    render(Counter);
    expect(screen.getByTestId('count')).toHaveTextContent('0');
  });

  it('increments count when + button is clicked', async () => {
    render(Counter);
    await fireEvent.click(screen.getByLabelText('Increment'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });

  it('decrements count when − button is clicked', async () => {
    render(Counter);
    await fireEvent.click(screen.getByLabelText('Decrement'));
    expect(screen.getByTestId('count')).toHaveTextContent('-1');
  });

  it('handles multiple clicks correctly', async () => {
    render(Counter);
    const inc = screen.getByLabelText('Increment');
    await fireEvent.click(inc);
    await fireEvent.click(inc);
    await fireEvent.click(inc);
    expect(screen.getByTestId('count')).toHaveTextContent('3');
  });
});

For components that accept props:

import { render } from '@testing-library/svelte';
import UserCard from './UserCard.svelte';

it('renders the user name passed as prop', () => {
  render(UserCard, { props: { name: 'Ada Lovelace', role: 'admin' } });
  expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
  expect(screen.getByText('admin')).toBeInTheDocument();
});

Testing SvelteKit Load Functions

SvelteKit's load functions are server-side (or universal) functions that fetch data before a route renders. They're plain TypeScript — test them without rendering anything.

// src/routes/posts/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params, fetch }) => {
  const response = await fetch(`/api/posts/${params.slug}`);

  if (!response.ok) {
    throw error(404, 'Post not found');
  }

  const post = await response.json();
  return { post };
};
// src/routes/posts/[slug]/page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from './+page.server';

describe('posts load function', () => {
  it('returns post data when API responds successfully', async () => {
    const mockPost = { id: 1, slug: 'hello-world', title: 'Hello World' };

    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => mockPost,
    });

    const result = await load({
      params: { slug: 'hello-world' },
      fetch: mockFetch,
    } as any);

    expect(result.post).toEqual(mockPost);
    expect(mockFetch).toHaveBeenCalledWith('/api/posts/hello-world');
  });

  it('throws a 404 error when the post does not exist', async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });

    await expect(
      load({ params: { slug: 'does-not-exist' }, fetch: mockFetch } as any)
    ).rejects.toMatchObject({ status: 404 });
  });

  it('calls the API with the correct slug from params', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ slug: 'specific-slug' }),
    });

    await load({ params: { slug: 'specific-slug' }, fetch: mockFetch } as any);

    expect(mockFetch).toHaveBeenCalledWith('/api/posts/specific-slug');
  });
});

Load function tests are fast (no browser, no server startup) and catch the class of bugs that component tests miss: wrong API URLs, missing error handling, and broken destructuring of the response shape.

Testing SvelteKit Form Actions

Form actions handle POST requests from <form> elements. Test them directly by constructing a fake RequestEvent.

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

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email')?.toString().trim();
    const message = data.get('message')?.toString().trim();

    if (!email || !email.includes('@')) {
      return fail(422, { error: 'Valid email is required', email });
    }

    if (!message || message.length < 10) {
      return fail(422, { error: 'Message must be at least 10 characters' });
    }

    await sendContactEmail({ email, message });
    throw redirect(303, '/contact/thanks');
  },
};
// src/routes/contact/page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { actions } from './+page.server';

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

function makeRequest(fields: Record<string, string>) {
  const formData = new FormData();
  for (const [key, value] of Object.entries(fields)) {
    formData.append(key, value);
  }
  return { formData: async () => formData } as any;
}

describe('contact form action', () => {
  it('returns a 422 when email is missing', async () => {
    const result = await actions.default({
      request: makeRequest({ message: 'This is a long enough message' }),
    } as any);

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

  it('returns a 422 when email is invalid', async () => {
    const result = await actions.default({
      request: makeRequest({ email: 'not-an-email', message: 'Long enough message here' }),
    } as any);

    expect(result?.status).toBe(422);
  });

  it('returns a 422 when message is too short', async () => {
    const result = await actions.default({
      request: makeRequest({ email: 'user@example.com', message: 'Short' }),
    } as any);

    expect(result?.status).toBe(422);
    expect(result?.data?.error).toMatch(/10 characters/i);
  });

  it('redirects to /contact/thanks on valid submission', async () => {
    await expect(
      actions.default({
        request: makeRequest({
          email: 'user@example.com',
          message: 'This is a valid message that is long enough.',
        }),
      } as any)
    ).rejects.toMatchObject({ status: 303, location: '/contact/thanks' });
  });
});

Testing form actions this way verifies every validation branch and the success redirect without spinning up a dev server. Add these before writing validation logic — they fail, then you implement.

Playwright E2E for SvelteKit

Playwright tests the full application running in a real browser. SvelteKit ships with a @playwright/test integration you can opt into during npm create svelte@latest, or add manually:

npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run build && npm run preview',
    port: 4173,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4173',
  },
});
// e2e/contact.test.ts
import { test, expect } from '@playwright/test';

test.describe('contact form', () => {
  test('shows validation error for missing email', async ({ page }) => {
    await page.goto('/contact');
    await page.fill('[name="message"]', 'This is a valid message with enough characters.');
    await page.click('[type="submit"]');
    await expect(page.getByText(/valid email is required/i)).toBeVisible();
  });

  test('submits successfully and redirects to thank-you page', async ({ page }) => {
    await page.goto('/contact');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="message"]', 'This is a valid message with enough characters.');
    await page.click('[type="submit"]');
    await expect(page).toHaveURL('/contact/thanks');
    await expect(page.getByText(/thank you/i)).toBeVisible();
  });

  test('preserves email field value after failed validation', async ({ page }) => {
    await page.goto('/contact');
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="message"]', 'Short');
    await page.click('[type="submit"]');
    await expect(page.locator('[name="email"]')).toHaveValue('user@example.com');
  });
});

Run E2E tests with npx playwright test. Add them to CI on pull requests against staging, not every commit — they take 20–60 seconds per test file.

What Your Tests Still Miss

Your Vitest and Playwright tests cover code paths you wrote. They don't cover what happens in production:

  • A third-party API your load function calls goes down or changes its response shape
  • A deployment introduces a build error that breaks SSR on specific routes
  • A race condition in form submission causes duplicate requests under real network latency
  • A user on an older browser hits a JS compatibility issue your jsdom tests can't reproduce
  • Your site loads slowly after a dependency update and users bounce before the page renders

These failures don't show up in unit tests because they're environmental. You need something running against the real production URL on a schedule.

Monitoring SvelteKit Apps in Production with HelpMeTest

Install HelpMeTest:

curl -fsSL https://helpmetest.com/install | bash

Write tests in plain English that run against your deployed app:

Go to https://yourapp.com/contact
Fill in "email" with "test@example.com"
Fill in "message" with "This is a test message for the contact form"
Click the submit button
Verify the page URL contains "/contact/thanks"
Verify the text "Thank you" is visible

HelpMeTest runs these on a cron schedule and alerts you when they fail. If your SvelteKit app breaks in production — a load function starts returning 500s, a route stops rendering, a form silently stops working — you know within minutes instead of finding out from a user ticket.

Free tier: 10 tests, unlimited health checks. Pro: $100/month for full monitoring coverage across all your routes.

Your Vitest tests prove the code is correct. HelpMeTest proves the deployed app is working. You need both.


Start at helpmetest.com — free tier, no credit card.

Read more