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

Basic Pattern

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

test('user registration flow', async ({ page }) => {
	await page.goto('/register');

	await page.getByLabelText('Email').fill('[email protected]');
	await page.getByLabelText('Password').fill('secure123');
	await page.getByRole('button', { name: 'Register' }).click();

	// Tests the complete client-server integration
	await expect(page.getByText('Welcome!')).toBeVisible();
});

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.

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

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

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { onMount } from 'svelte';

	let { children } = $props();

	onMount(() => {
		document.documentElement.toggleAttribute('hydrated', true);
	});
</script>

{@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

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

test('submit form after hydration', async ({ page }) => {
	await page.goto('/contact');

	// Wait for hydration to complete
	await expect(page.locator(':root')).toHaveAttribute('hydrated');

	// Now interactions are safe
	await page.getByRole('button', { name: 'Send' }).click();
	await expect(page.getByText('Message sent')).toBeVisible();
});

Page Object Model Abstraction

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

// e2e/models/base-page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class BasePage {
	readonly page: Page;

	constructor(page: Page) {
		this.page = page;
	}

	async goto(path: string) {
		await this.page.goto(path);
		await this.hydrated();
	}

	async hydrated() {
		await expect(this.page.locator(':root')).toHaveAttribute(
			'hydrated',
		);
	}
}
// e2e/models/contact-page.ts
import { BasePage } from './base-page';

export class ContactPage extends BasePage {
	async submitForm(message: string) {
		await this.page
			.getByRole('textbox', { name: 'Message' })
			.fill(message);
		await this.page.getByRole('button', { name: 'Send' }).click();
	}
}
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test';
import { ContactPage } from './models/contact-page';

test('submit contact form', async ({ page }) => {
	const contact = new ContactPage(page);
	await contact.goto('/contact');

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

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.