SvelteKit E2E Testing with Playwright (2026)

SvelteKit E2E Testing with Playwright (2026)

Playwright is the E2E test runner of choice for SvelteKit. It drives a real browser — Chromium, Firefox, or WebKit — against your running app. Unlike Vitest component tests that run in jsdom, Playwright tests exercise real browser behavior: navigation, cookies, local storage, JavaScript rendering, and network requests.

This guide covers Playwright setup for SvelteKit, writing reliable tests, handling authentication, and running tests in CI.

Installing Playwright

Add Playwright to an existing SvelteKit project:

npm install -D @playwright/test
npx playwright install chromium

For all browsers:

npx playwright install

Configuring Playwright for SvelteKit

Create playwright.config.ts at the project root:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],

  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

This config:

  • Runs tests from the e2e/ folder
  • Starts the SvelteKit preview server before tests
  • Takes screenshots on failures
  • Retries twice in CI to handle flakiness

For development with hot reload, use the dev server instead:

webServer: {
  command: 'npm run dev',
  url: 'http://localhost:5173',
  reuseExistingServer: !process.env.CI,
},
use: {
  baseURL: 'http://localhost:5173',
},

Writing Your First E2E Tests

Create the e2e/ directory and add a test file:

// e2e/home.test.ts
import { test, expect } from '@playwright/test';

test('home page loads and shows main heading', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveTitle(/My App/);
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('navigation links work', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: 'Blog' }).click();

  await expect(page).toHaveURL('/blog');
  await expect(page.getByRole('heading', { name: 'Blog' })).toBeVisible();
});

test('404 page is shown for unknown routes', async ({ page }) => {
  await page.goto('/this-route-does-not-exist');

  await expect(page.getByText(/not found/i)).toBeVisible();
});

Run the tests:

npx playwright test
npx playwright <span class="hljs-built_in">test --ui          <span class="hljs-comment"># interactive mode
npx playwright <span class="hljs-built_in">test --headed      <span class="hljs-comment"># watch the browser

Testing Forms

Forms are among the most important things to test end-to-end. Validation, submission, redirects, and error states all need real browser verification.

// e2e/contact.test.ts
import { test, expect } from '@playwright/test';

test.describe('Contact form', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/contact');
  });

  test('shows validation errors when submitted empty', async ({ page }) => {
    await page.getByRole('button', { name: /send/i }).click();

    await expect(page.getByText(/email is required/i)).toBeVisible();
    await expect(page.getByText(/message is required/i)).toBeVisible();
  });

  test('shows error for invalid email format', async ({ page }) => {
    await page.fill('[name="email"]', 'not-an-email');
    await page.fill('[name="message"]', 'This is a long enough message here.');
    await page.getByRole('button', { name: /send/i }).click();

    await expect(page.getByText(/valid email/i)).toBeVisible();
  });

  test('submits successfully and redirects', async ({ page }) => {
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="message"]', 'This is a complete test message for the contact form.');
    await page.getByRole('button', { name: /send/i }).click();

    await expect(page).toHaveURL('/contact/thanks');
    await expect(page.getByText(/thank you/i)).toBeVisible();
  });

  test('preserves entered values after failed validation', async ({ page }) => {
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="message"]', 'Short');
    await page.getByRole('button', { name: /send/i }).click();

    await expect(page.locator('[name="email"]')).toHaveValue('user@example.com');
  });
});

Testing Authentication

Authentication tests need a way to set up the logged-in state without repeating login steps in every test. Use Playwright's storageState to capture session state once and reuse it.

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.playwright/auth.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');

  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'testpassword123');
  await page.getByRole('button', { name: /log in/i }).click();

  await expect(page).toHaveURL('/dashboard');

  // Save session to file
  await page.context().storageState({ path: authFile });
});

Configure Playwright to use the saved auth state:

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '.playwright/auth.json');

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'authenticated',
      use: { storageState: authFile },
      dependencies: ['setup'],
    },
  ],
});

Tests in the authenticated project start already logged in:

// e2e/dashboard.test.ts
import { test, expect } from '@playwright/test';

// This test runs with saved auth state — no login needed
test('dashboard shows user data', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page.getByText('Welcome back')).toBeVisible();
  await expect(page.getByRole('navigation')).toContainText('My Account');
});

test('user can update profile', async ({ page }) => {
  await page.goto('/dashboard/profile');

  await page.fill('[name="displayName"]', 'Updated Name');
  await page.getByRole('button', { name: /save/i }).click();

  await expect(page.getByText(/saved successfully/i)).toBeVisible();
});

Mocking API Calls

For tests that depend on external APIs or need deterministic data, intercept network requests with page.route():

// e2e/products.test.ts
import { test, expect } from '@playwright/test';

test('products page shows items from API', async ({ page }) => {
  // Intercept the API call and return mock data
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Widget Pro', price: 49.99 },
        { id: 2, name: 'Gadget Plus', price: 29.99 },
      ]),
    });
  });

  await page.goto('/products');

  await expect(page.getByText('Widget Pro')).toBeVisible();
  await expect(page.getByText('$49.99')).toBeVisible();
  await expect(page.getByText('Gadget Plus')).toBeVisible();
});

test('shows empty state when API returns no products', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    });
  });

  await page.goto('/products');

  await expect(page.getByText(/no products found/i)).toBeVisible();
});

test('shows error message when API fails', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    await route.fulfill({ status: 500 });
  });

  await page.goto('/products');

  await expect(page.getByRole('alert')).toBeVisible();
});

Running in CI

Add Playwright to your CI pipeline. For GitHub Actions:

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run E2E tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

The --with-deps flag installs system dependencies needed for Chromium to run on Ubuntu. The artifact upload saves the HTML report on failures so you can review screenshots and traces.

Debugging Failing Tests

When a Playwright test fails:

# Run with visible browser
npx playwright <span class="hljs-built_in">test --headed

<span class="hljs-comment"># Slow down execution to watch what's happening
npx playwright <span class="hljs-built_in">test --headed --slow-mo=500

<span class="hljs-comment"># Open trace viewer for a specific test run
npx playwright show-trace test-results/*/trace.zip

<span class="hljs-comment"># Generate and open the HTML report
npx playwright show-report

Use page.pause() to stop execution and open the Playwright Inspector:

test('debugging example', async ({ page }) => {
  await page.goto('/dashboard');
  await page.pause(); // execution stops here, Inspector opens
  await page.getByRole('button', { name: /save/i }).click();
});

What E2E Tests Don't Cover

Playwright tests your app running on localhost. After deployment:

  • Your production environment may have different environment variables, causing subtle differences
  • A database migration may break queries that worked in test
  • CDN caching can serve stale assets after deployment
  • Third-party integrations (payment processors, email providers) may fail silently
  • Monitoring endpoints may return 200 but the app logic is broken

Running Playwright in CI is necessary but not sufficient. You need tests that run against production continuously.

Production Monitoring with HelpMeTest

HelpMeTest runs tests against your deployed SvelteKit app on a schedule. Write tests in plain English:

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

When your SvelteKit app breaks in production — a form action fails, a load function errors, a route returns a blank page — HelpMeTest catches it within minutes and sends an alert.

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