E2E Testing SvelteKit with Playwright: Routes, Forms, and Auth Flows

E2E Testing SvelteKit with Playwright: Routes, Forms, and Auth Flows

SvelteKit's client-side routing and form enhancements make it an excellent target for Playwright E2E tests. Playwright handles SPA navigation natively, and SvelteKit's progressive enhancement means forms work both with and without JavaScript — making tests more realistic.

Setup

SvelteKit includes Playwright support out of the box:

npm create svelte@latest my-app
# Select "Add Playwright for browser testing" during setup

# Or add Playwright to an existing project
npm install -D @playwright/test
npx playwright install

Configure 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,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

Testing Navigation

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

test.describe('navigation', () => {
  test('home page loads', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveTitle(/My SvelteKit App/);
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  });

  test('navigates to about page via link', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'About' }).click();

    await expect(page).toHaveURL('/about');
    await expect(page.getByRole('heading')).toContainText('About');
  });

  test('back navigation works', async ({ page }) => {
    await page.goto('/products');
    await page.getByRole('link', { name: 'Widget' }).click();
    await expect(page).toHaveURL(/\/products\/\d+/);

    await page.goBack();
    await expect(page).toHaveURL('/products');
  });

  test('404 page displays for unknown routes', async ({ page }) => {
    await page.goto('/this-does-not-exist');
    await expect(page.getByText('404')).toBeVisible();
  });
});

Testing SvelteKit Forms

SvelteKit forms with use:enhance provide progressive enhancement. Test the enhanced (JavaScript) path:

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

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

  test('submits successfully with valid data', async ({ page }) => {
    await page.getByLabel('Name').fill('Alice Smith');
    await page.getByLabel('Email').fill('alice@example.com');
    await page.getByLabel('Message').fill('Hello, this is a test message.');

    await page.getByRole('button', { name: 'Send' }).click();

    await expect(page.getByText('Message sent!')).toBeVisible();
  });

  test('shows validation errors on empty submit', async ({ page }) => {
    await page.getByRole('button', { name: 'Send' }).click();

    await expect(page.getByText('Name is required')).toBeVisible();
    await expect(page.getByText('Email is required')).toBeVisible();
  });

  test('shows error on invalid email', async ({ page }) => {
    await page.getByLabel('Name').fill('Bob');
    await page.getByLabel('Email').fill('not-an-email');
    await page.getByRole('button', { name: 'Send' }).click();

    await expect(page.getByText('Invalid email')).toBeVisible();
  });

  test('retains form values after validation failure', async ({ page }) => {
    await page.getByLabel('Name').fill('Charlie');
    await page.getByRole('button', { name: 'Send' }).click();

    // Name should be preserved even after failed submission
    await expect(page.getByLabel('Name')).toHaveValue('Charlie');
  });
});

Testing Authentication Flows

// e2e/auth.spec.ts
import { test, expect, type Page } from '@playwright/test';

async function login(page: Page, email: string, password: string) {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Log in' }).click();
}

test.describe('authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    await login(page, 'user@example.com', 'password123');
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('invalid credentials shows error', async ({ page }) => {
    await login(page, 'user@example.com', 'wrong-password');
    await expect(page).toHaveURL('/login');
    await expect(page.getByRole('alert')).toContainText('Invalid credentials');
  });

  test('protected routes redirect to login when unauthenticated', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login');
  });

  test('logout clears session', async ({ page }) => {
    await login(page, 'user@example.com', 'password123');
    await page.getByRole('button', { name: 'Log out' }).click();

    // Verify session is gone
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login');
  });
});

Authenticated State with storageState

Avoid logging in before every test — save and reuse session state:

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

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Log in' }).click();

  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts — use the auth setup project
export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'authenticated',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'unauthenticated',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Testing Dynamic Routes

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

test.describe('product pages', () => {
  test('product list renders items', async ({ page }) => {
    await page.goto('/products');

    const items = page.getByRole('listitem');
    await expect(items).toHaveCount(10); // or use toBeGreaterThan
  });

  test('product detail loads from URL param', async ({ page }) => {
    await page.goto('/products/1');

    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
    await expect(page.getByTestId('price')).toContainText('$');
  });

  test('add to cart flow', async ({ page }) => {
    await page.goto('/products/1');
    await page.getByRole('button', { name: 'Add to cart' }).click();

    // Cart count in nav should update
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });
});

Testing SvelteKit Loading States

test('shows loading skeleton during navigation', async ({ page }) => {
  // Throttle network to see loading state
  await page.route('/api/products*', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 500));
    await route.continue();
  });

  await page.goto('/products');

  // Check skeleton is visible during load
  await expect(page.getByTestId('skeleton')).toBeVisible();

  // Wait for content
  await expect(page.getByRole('list')).toBeVisible();
  await expect(page.getByTestId('skeleton')).not.toBeVisible();
});

Intercepting API Calls

test('handles API errors gracefully', async ({ page }) => {
  await page.route('/api/products', (route) => {
    route.fulfill({
      status: 500,
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });

  await page.goto('/products');

  await expect(page.getByRole('alert')).toContainText('Failed to load products');
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

Page Object Model for SvelteKit

// e2e/pages/LoginPage.ts
import { type Page, expect } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Log in' }).click();
  }

  async expectError(message: string) {
    await expect(this.page.getByRole('alert')).toContainText(message);
  }

  async expectRedirectTo(url: string) {
    await expect(this.page).toHaveURL(url);
  }
}

// Usage
test('login with POM', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await loginPage.expectRedirectTo('/dashboard');
});

Running Tests

# Run all E2E tests
npx playwright test

# Run with UI mode
npx playwright test --ui

# Run specific file
npx playwright test e2e/auth.spec.ts

# Run against specific browser
npx playwright test --project=chromium

# Debug mode
npx playwright test --debug

Key Patterns

  • Use storageState to save auth sessions — never log in inside every test
  • Mock slow APIs with page.route() to test loading states without waiting
  • Use getByRole and getByLabel over CSS selectors — they survive refactors
  • Test progressive enhancement by disabling JavaScript in some tests to verify forms work without JS
  • Parameterize fixtures for different user roles (admin, regular, guest)
  • Add data-testid sparingly — prefer accessible locators when possible

SvelteKit's server-first architecture means you can often test the same behavior at multiple levels: unit test the load function, then verify the rendered output with Playwright. Start with unit tests for logic and use E2E for user flows that span multiple pages.

Read more

Start now free