Testing Svelte Components with Playwright CT

Testing Svelte Components with Playwright CT

@playwright/experimental-ct-svelte brings the same mount-in-browser approach to Svelte that the React and Vue adapters provide. The component runs in a real browser, in a real DOM, with real Svelte reactivity — not a jsdom approximation. For Svelte specifically, this matters because Svelte's compiled output is DOM-centric in ways that don't always behave the same in simulated environments.

Svelte 5 introduced runes, a new reactivity primitive that changes how state and effects are declared. This post covers both the setup and practical patterns for Svelte 5 rune-based components, with notes on where Svelte 4 differs.

Setup

npm install --save-dev @playwright/experimental-ct-svelte

Create playwright-ct.config.ts:

import { defineConfig, devices } from '@playwright/experimental-ct-svelte';

export default defineConfig({
  testDir: './',
  testMatch: '**/*.ct.spec.ts',
  use: {
    ctPort: 3100,
    ctViteConfig: {
      plugins: [], // Svelte plugin is added automatically by the CT adapter
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Create the CT mounting point:

<!-- playwright/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>CT Sandbox</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.ts"></script>
  </body>
</html>
// playwright/index.ts
import { beforeMount, afterMount } from '@playwright/experimental-ct-svelte/hooks';

beforeMount(async ({ component }) => {
  // Global setup: add stores, context, etc.
});

Basic mount() Usage

The mount function accepts a Svelte component and its props:

// Counter.ct.spec.ts
import { test, expect } from '@playwright/experimental-ct-svelte';
import Counter from './Counter.svelte';

test('increments count on click', async ({ mount }) => {
  const component = await mount(Counter, {
    props: {
      initialCount: 5,
    },
  });

  await expect(component.getByText('Count: 5')).toBeVisible();
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByText('Count: 6')).toBeVisible();
});

The Counter.svelte component using Svelte 5 runes:

<!-- Counter.svelte -->
<script lang="ts">
  let { initialCount = 0 }: { initialCount?: number } = $props();
  
  let count = $state(initialCount);
</script>

<p>Count: {count}</p>
<button onclick={() => count++}>Increment</button>
<button onclick={() => count--}>Decrement</button>

Testing Two-Way Binding and Events

Svelte's event system changed in Svelte 5. Callbacks are now props rather than on:event directives. Testing them follows the same pattern as any prop function:

<!-- TextInput.svelte -->
<script lang="ts">
  let {
    value = $bindable(''),
    onchange,
    label,
  }: {
    value?: string;
    onchange?: (v: string) => void;
    label: string;
  } = $props();
</script>

<label>
  {label}
  <input
    bind:value
    oninput={(e) => onchange?.(e.currentTarget.value)}
  />
</label>
test('calls onchange with updated value', async ({ mount }) => {
  let capturedValue = '';

  const component = await mount(TextInput, {
    props: {
      label: 'Username',
      onchange: (v: string) => { capturedValue = v; },
    },
  });

  await component.getByLabel('Username').fill('alice');
  expect(capturedValue).toBe('alice');
});

For testing the two-way bound value prop specifically, use component.update() to change props after mount:

test('reflects external prop change', async ({ mount }) => {
  const component = await mount(TextInput, {
    props: { label: 'Email', value: 'initial@example.com' },
  });

  await expect(component.getByLabel('Email')).toHaveValue('initial@example.com');

  await component.update({ props: { value: 'updated@example.com' } });

  await expect(component.getByLabel('Email')).toHaveValue('updated@example.com');
});

Testing Svelte Stores Inside Components

Components that consume Svelte stores need the stores initialized before mounting. Inject them via context or set their values directly:

// store.ts
import { writable } from 'svelte/store';
export const userStore = writable<{ name: string } | null>(null);
<!-- UserGreeting.svelte -->
<script lang="ts">
  import { userStore } from './store';
</script>

{#if $userStore}
  <p>Hello, {$userStore.name}</p>
{:else}
  <p>Not logged in</p>
{/if}
// UserGreeting.ct.spec.ts
import { test, expect } from '@playwright/experimental-ct-svelte';
import { userStore } from './store';
import UserGreeting from './UserGreeting.svelte';

test('shows user name when logged in', async ({ mount }) => {
  userStore.set({ name: 'Alice' });

  const component = await mount(UserGreeting);

  await expect(component.getByText('Hello, Alice')).toBeVisible();
});

test('shows logged out state', async ({ mount }) => {
  userStore.set(null);

  const component = await mount(UserGreeting);

  await expect(component.getByText('Not logged in')).toBeVisible();
});

The store module is shared between the test process and the component (they run in the same Vite bundle in the CT environment), so setting the store value before mount() works as expected.

Testing Svelte Transitions

Svelte's transition: directives attach CSS animations on element enter/leave. In tests, these transitions complete asynchronously. Use waitFor to wait for the animated state:

<!-- Notification.svelte -->
<script lang="ts">
  import { fade } from 'svelte/transition';
  
  let { message, show }: { message: string; show: boolean } = $props();
</script>

{#if show}
  <div transition:fade={{ duration: 300 }} role="alert">
    {message}
  </div>
{/if}
test('notification fades in when shown', async ({ mount }) => {
  const component = await mount(Notification, {
    props: { message: 'Saved!', show: false },
  });

  await expect(component.getByRole('alert')).not.toBeVisible();

  await component.update({ props: { show: true } });

  // Wait for the transition to complete
  await expect(component.getByRole('alert')).toBeVisible();
  await expect(component.getByRole('alert')).toHaveText('Saved!');
});

test('notification disappears on hide', async ({ mount }) => {
  const component = await mount(Notification, {
    props: { message: 'Saved!', show: true },
  });

  await expect(component.getByRole('alert')).toBeVisible();

  await component.update({ props: { show: false } });

  // The fade-out takes 300ms; waitFor handles the async removal
  await expect(component.getByRole('alert')).not.toBeVisible({ timeout: 1000 });
});

For complex transitions or animations driven by Svelte's animate: directive, disable them in the Vite config for most test runs and keep a dedicated transition.spec.ts for the transition-specific tests:

// playwright-ct.config.ts
use: {
  ctViteConfig: {
    define: {
      'import.meta.env.TEST': 'true',
    },
  },
},
<!-- In components, conditionally skip transitions in tests -->
<div transition:fade={import.meta.env.TEST ? { duration: 0 } : { duration: 300 }}>

Snapshot Testing Component Output

test('renders correctly', async ({ mount, page }) => {
  const component = await mount(Counter, {
    props: { initialCount: 0 },
  });

  await expect(component).toHaveScreenshot('counter-initial.png');
});

Component-scoped screenshots clip to the component's bounding box automatically.

Svelte 5 Runes: $derived and $effect

Testing components that use $derived and $effect is no different from testing regular prop-driven components — the reactivity is an implementation detail. What you test is the observable DOM output:

<!-- PriceDisplay.svelte -->
<script lang="ts">
  let { price, quantity }: { price: number; quantity: number } = $props();
  
  let total = $derived(price * quantity);
  let formattedTotal = $derived(
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(total)
  );
</script>

<p data-testid="total">{formattedTotal}</p>
test('displays correct total for given price and quantity', async ({ mount }) => {
  const component = await mount(PriceDisplay, {
    props: { price: 19.99, quantity: 3 },
  });

  await expect(component.getByTestId('total')).toHaveText('$59.97');
});

test('updates total when props change', async ({ mount }) => {
  const component = await mount(PriceDisplay, {
    props: { price: 10, quantity: 1 },
  });

  await expect(component.getByTestId('total')).toHaveText('$10.00');

  await component.update({ props: { quantity: 5 } });

  await expect(component.getByTestId('total')).toHaveText('$50.00');
});

Comparison with @testing-library/svelte

@testing-library/svelte runs in jsdom (or happy-dom) and uses a virtual DOM. It's faster to start and has a large ecosystem of utilities. Playwright CT runs in a real browser and is slower per test.

The tradeoff:

  • @testing-library/svelte: Better for pure logic, form validation, conditional rendering, accessibility assertions. No browser dependencies. Runs in milliseconds.
  • Playwright CT: Required for CSS-dependent behavior (layout, visibility driven by CSS classes, pseudo-states), browser APIs (ResizeObserver, IntersectionObserver, WebGL, clipboard), and integration with other browser features. Also lets you use the full Playwright assertion and wait API, which is more reliable for async state.

The pragmatic answer for most Svelte projects is both: @testing-library/svelte for the majority of component tests, Playwright CT for components with browser-specific behavior or complex interaction flows.

Tools like HelpMeTest handle the layer above both: full user journeys through the deployed application, where the component composition, routing, and server behavior all interact.

Svelte's compiled output is one of the cleanest in the frontend ecosystem. It deserves tests that reflect how it actually runs.

Start now free