3 min read
On this page

E2E Testing with Playwright

End-to-end tests verify your application as a user experiences it. Playwright launches a real browser, navigates to your SvelteKit app, fills in forms, clicks buttons, and asserts on what appears on the page. Unlike unit tests that test components in isolation, E2E tests exercise the full stack: SSR, routing, load functions, form actions, API calls, and database interactions all running together.

Setting Up Playwright

SvelteKit projects can include Playwright during npx sv create. To add it manually:

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

const config: PlaywrightTestConfig = {
  webServer: {
    command: 'npm run build && npm run preview',
    port: 4173,
    reuseExistingServer: !process.env.CI
  },
  testDir: 'e2e',
  testMatch: '**/*.test.ts',
  use: {
    baseURL: 'http://localhost:4173'
  }
};

export default config;

The webServer configuration builds your app and starts the preview server before tests run. Playwright waits for the server to be ready, then executes tests against it.

Navigate to pages and assert on their content:

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

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

  await expect(page).toHaveTitle('My App');
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

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

  // Click a link and verify navigation
  await page.getByRole('link', { name: 'About' }).click();

  await expect(page).toHaveURL('/about');
  await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});

test('dynamic routes load correct data', async ({ page }) => {
  await page.goto('/blog/my-first-post');

  await expect(page.getByRole('heading', { name: 'My First Post' })).toBeVisible();
  await expect(page.getByText('Published on')).toBeVisible();
});

Testing Form Submissions

Test the complete form action cycle: submit data, handle validation, verify the result:

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

test('login with valid credentials', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('Password').fill('correct-password');
  await page.getByRole('button', { name: 'Log In' }).click();

  // Should redirect to the app
  await expect(page).toHaveURL('/app');
  await expect(page.getByText('Welcome back, Alice')).toBeVisible();
});

test('login with invalid credentials shows error', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('Password').fill('wrong-password');
  await page.getByRole('button', { name: 'Log In' }).click();

  // Should stay on login page with error
  await expect(page).toHaveURL('/login');
  await expect(page.getByText('Invalid email or password')).toBeVisible();
});

test('form validation shows field errors', async ({ page }) => {
  await page.goto('/register');

  // Submit empty form
  await page.getByRole('button', { name: 'Create Account' }).click();

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

Testing SSR

Verify that server-side rendering works by checking the initial HTML before JavaScript hydrates:

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

test('page content is server-rendered', async ({ page }) => {
  // Disable JavaScript to see only SSR content
  await page.route('**/*.js', (route) => route.abort());

  await page.goto('/blog/my-first-post');

  // Content should be visible even without JS
  await expect(page.getByRole('heading', { name: 'My First Post' })).toBeVisible();
  await expect(page.getByText('This is the blog post content')).toBeVisible();
});

test('meta tags are rendered server-side', async ({ page }) => {
  await page.goto('/blog/my-first-post');

  // Check Open Graph tags
  const ogTitle = page.locator('meta[property="og:title"]');
  await expect(ogTitle).toHaveAttribute('content', 'My First Post');

  const ogDescription = page.locator('meta[property="og:description"]');
  await expect(ogDescription).toHaveAttribute('content', /blog post/);
});

Testing Protected Routes

Verify that authentication and authorization work end-to-end:

// e2e/protected-routes.test.ts
import { expect, test } from '@playwright/test';

test('unauthenticated user is redirected to login', async ({ page }) => {
  await page.goto('/app/dashboard');

  // Should redirect to login with return URL
  await expect(page).toHaveURL(/\/login\?returnTo/);
});

test('authenticated user can access protected routes', async ({ page }) => {
  // Log in first
  await page.goto('/login');
  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('Password').fill('correct-password');
  await page.getByRole('button', { name: 'Log In' }).click();

  // Now access the protected route
  await page.goto('/app/dashboard');
  await expect(page).toHaveURL('/app/dashboard');
  await expect(page.getByText('Dashboard')).toBeVisible();
});

For tests that need an authenticated state, create a reusable login helper:

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

export async function loginAs(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();
  await page.waitForURL('/app');
}
// e2e/dashboard.test.ts
import { expect, test } from '@playwright/test';
import { loginAs } from './helpers/auth';

test.beforeEach(async ({ page }) => {
  await loginAs(page, 'alice@example.com', 'correct-password');
});

test('dashboard shows user stats', async ({ page }) => {
  await page.goto('/app/dashboard');
  await expect(page.getByText('Active Projects')).toBeVisible();
});

Testing Form Actions with Progressive Enhancement

SvelteKit forms work with and without JavaScript. Test both paths:

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

test('form works with JavaScript enabled', async ({ page }) => {
  await page.goto('/contact');

  await page.getByLabel('Name').fill('Alice');
  await page.getByLabel('Message').fill('Hello there');
  await page.getByRole('button', { name: 'Send' }).click();

  // With JS, the page updates without a full reload
  await expect(page.getByText('Message sent')).toBeVisible();
});

test('form works without JavaScript', async ({ page }) => {
  // Disable JavaScript
  await page.route('**/*.js', (route) => route.abort());

  await page.goto('/contact');

  await page.getByLabel('Name').fill('Alice');
  await page.getByLabel('Message').fill('Hello there');
  await page.getByRole('button', { name: 'Send' }).click();

  // Without JS, the page does a full reload
  await expect(page.getByText('Message sent')).toBeVisible();
});

CI Integration

Run Playwright tests in GitHub Actions:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Install only the browsers you need. Chromium alone covers most cases and is faster to install than the full browser set.

The Test Pyramid

A healthy test suite follows the pyramid shape:

Unit tests (many, fast): Test individual components and functions with Vitest. They run in milliseconds, catch logic errors, and are cheap to write.

Integration tests (some): Test component interactions, store behavior, and module boundaries. Still use Vitest but render larger component trees.

E2E tests (few, slow): Test critical user journeys with Playwright. They are slow, fragile, and expensive to maintain, but catch integration issues that unit tests miss.

            /\
           /  \      E2E: Login flow, checkout, form submissions
          /    \     (few, slow, Playwright)
         /------\
        /        \   Integration: Component trees, store interactions
       /          \  (some, moderate, Vitest)
      /------------\
     /              \ Unit: Components, functions, utilities
    /                \ (many, fast, Vitest)
   /------------------\

Write E2E tests for the critical paths that generate revenue or are core to the product. Do not E2E test every edge case. That is what unit tests are for.

Running Playwright Tests

# Run all E2E tests
npx playwright test

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

# Run in headed mode (see the browser)
npx playwright test --headed

# Run in debug mode (step through)
npx playwright test --debug

# Show the HTML report
npx playwright show-report

Common Pitfalls

  • Testing too much with E2E. E2E tests are slow and brittle. Test critical user journeys, not every possible interaction. Use unit tests for edge cases and validation logic.
  • Flaky tests from timing issues. Use Playwright's built-in waiting mechanisms like expect(locator).toBeVisible() instead of arbitrary page.waitForTimeout() calls. Playwright auto-retries assertions.
  • Not using the web server configuration. Without webServer in the config, you must manually start the app before running tests. Let Playwright handle it.
  • Hardcoding test data. Tests that depend on specific database state break when the data changes. Use test fixtures or seed the database before each test run.
  • Not uploading artifacts on failure. Without the Playwright report, debugging CI failures is guessing. Always upload the report as an artifact when tests fail.

Key Takeaways

  • Playwright tests exercise the full SvelteKit stack: SSR, routing, load functions, form actions, and client-side interactions.
  • Use webServer in Playwright config to automatically build and start your app before tests.
  • Test critical user journeys: login, form submission, navigation, protected routes.
  • Verify SSR by disabling JavaScript and checking that content is still visible.
  • Follow the test pyramid: many unit tests, fewer integration tests, few E2E tests.
  • Use Playwright's auto-waiting assertions instead of manual timeouts to avoid flaky tests.
  • Run Playwright in CI with npx playwright install --with-deps chromium for faster setup.