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 assertion | With 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 retry | Deterministic 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/catchandtest.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.