E2E Testing

The Final Safety Net

E2E testing completes the Client-Server Alignment Strategy by testing the full user journey from browser to server and back.

Quick Overview

E2E tests validate:

  • Complete form submission flows
  • Client-server integration
  • Real network requests
  • Full user workflows

Configuration and Colocation

The official Svelte CLI Playwright add-on uses a filename pattern, not a dedicated test directory:

1// playwright.config.ts
2import { defineConfig } from '@playwright/test';
3
4export default defineConfig({
5	webServer: {
6		command: 'npm run build && npm run preview',
7		port: 4173,
8	},
9	testMatch: '**/*.e2e.{ts,js}',
10});

Keep E2E tests beside the route they exercise, for example src/routes/contact/page.svelte.e2e.ts. Cross-route journeys can live at src/routes/. Component tests use .svelte.test.ts, Node tests use .test.ts, and optional SSR tests use .ssr.test.ts. Vitest does not match .e2e.ts, so all four types can safely share a directory.

Run pnpm exec playwright install chromium once, then pnpm test:e2e. Use pnpm test:e2e --list to check collection without starting the app. Unlike isolated component tests, E2E tests should submit real forms.

Basic Pattern

1// src/routes/register/page.svelte.e2e.ts
2import { test, expect } from '@playwright/test';
3
4test('user registration flow', async ({ page }) => {
5	await page.goto('/register');
6
7	await page.getByLabel('Email').fill('[email protected]');
8	await page.getByLabel('Password').fill('secure123');
9	await page.getByRole('button', { name: 'Register' }).click();
10
11	// Tests the complete client-server integration
12	await expect(page.getByText('Welcome!')).toBeVisible();
13});

Why E2E Matters

  • Catches client-server contract mismatches that unit tests miss
  • Validates real form submissions with actual FormData
  • Tests complete user workflows
  • Provides confidence in production deployments

Hydration Assertion Pattern

SvelteKit server-side renders your pages, meaning the HTML is visible in the browser before JavaScript has finished loading and hydrating the components. This creates a subtle but dangerous gap: Playwright can interact with SSR-rendered elements before they are interactive.

The Problem

A button rendered by SSR looks clickable, but its event handlers aren’t attached until hydration completes. Playwright doesn’t know about this — it sees a visible button and clicks it. Sometimes hydration finishes in time and the test passes. Sometimes it doesn’t and the test fails. This is a classic source of flaky E2E tests.

1// ❌ Flaky — may click before hydration completes
2test('submit form', async ({ page }) => {
3	await page.goto('/contact');
4	await page.getByRole('button', { name: 'Send' }).click();
5	// Sometimes works, sometimes doesn't
6});

The real issue is masked: you see a timeout or missing element error, not “hydration wasn’t complete.” This makes debugging painful.

The Solution: Hydration Signal

Set a hydrated attribute on <html> when hydration completes, then assert it in tests before interacting with the page.

Step 1: Add the signal in your root layout

1<!-- src/routes/+layout.svelte -->
2<script lang="ts">
3	import { onMount } from 'svelte';
4
5	let { children } = $props();
6
7	onMount(() => {
8		document.documentElement.toggleAttribute('hydrated', true);
9	});
10</script>
11
12{@render children?.()}

onMount only runs in the browser after hydration, making it the perfect hook for this signal.

Step 2: Assert hydration in your tests

1// src/routes/contact/page.svelte.e2e.ts
2import { test, expect } from '@playwright/test';
3
4test('submit form after hydration', async ({ page }) => {
5	await page.goto('/contact');
6
7	// Wait for hydration to complete
8	await expect(page.locator(':root')).toHaveAttribute('hydrated');
9
10	// Now interactions are safe
11	await page.getByRole('button', { name: 'Send' }).click();
12	await expect(page.getByText('Message sent')).toBeVisible();
13});

Page Object Model Abstraction

Extract the hydration check into a base Page Object Model class so every test gets it for free:

1// src/lib/testing/base-page.ts
2import { type Page, type Locator, expect } from '@playwright/test';
3
4export class BasePage {
5	readonly page: Page;
6
7	constructor(page: Page) {
8		this.page = page;
9	}
10
11	async goto(path: string) {
12		await this.page.goto(path);
13		await this.hydrated();
14	}
15
16	async hydrated() {
17		await expect(this.page.locator(':root')).toHaveAttribute(
18			'hydrated',
19		);
20	}
21}
1// src/lib/testing/contact-page.ts
2import { BasePage } from './base-page';
3
4export class ContactPage extends BasePage {
5	async submitForm(message: string) {
6		await this.page
7			.getByRole('textbox', { name: 'Message' })
8			.fill(message);
9		await this.page.getByRole('button', { name: 'Send' }).click();
10	}
11}
1// src/routes/contact/page.svelte.e2e.ts
2import { test, expect } from '@playwright/test';
3import { ContactPage } from '../../lib/testing/contact-page';
4
5test('submit contact form', async ({ page }) => {
6	const contact = new ContactPage(page);
7	await contact.goto('/contact');
8
9	await contact.submitForm('Hello!');
10	await expect(page.getByText('Message sent')).toBeVisible();
11});

Every page object that extends BasePage automatically waits for hydration on navigation — no boilerplate in individual tests.

Early Defect Detection

The hydration assertion pattern shifts debugging from symptoms to root causes:

Without hydration assertionWith hydration assertion
“Button click did nothing”“Hydration not complete”
“Element not found after navigation”“Hydration not complete”
“Form submission lost data”“Hydration not complete”
Flaky test — passes on retryDeterministic failure

When a test fails at the hydration assertion, you know immediately that the issue is hydration timing — not your test logic, not a backend bug, not a selector problem. This is especially valuable with progressive enhancement, where SSR forms may work differently before and after hydration.

When to Use This Pattern

  • Always for tests that interact with JavaScript-dependent elements (buttons with handlers, dynamic forms, client-side navigation)
  • Not needed for tests that only assert static content rendered by SSR (checking text, headings, links that work without JS)

Best Practices

  • Use test.step() to organize complex workflows into readable sections
  • Handle pages that may fail to load with try/catch and test.skip() rather than letting the whole suite fail
  • Use .first() when locators might match multiple elements to avoid strict mode violations
  • Prefer semantic locators (getByRole, getByLabel) over test IDs for accessibility coverage

Credit: The hydration assertion pattern is based on Hydration Assertion in Tests with SvelteKit & Playwright by @vnphanquang.