Skip to main content
Back to Blog
Guide
2026-05-09

Playwright Component Testing for Svelte: Complete 2026 Guide

Test Svelte and SvelteKit components with Playwright Component Testing in real browsers. Mount, props, stores, fixtures, and visual snapshots in TypeScript.

Playwright Component Testing for Svelte: Complete 2026 Guide

Svelte's compiler produces tight, fast components that often need lightweight test setup. Vitest in jsdom handles most unit needs, but for the components that touch real DOM events, browser APIs, or visual styling, Playwright Component Testing is the right tool. It mounts your Svelte component in a real browser, exercises it with the same locators as your end-to-end suite, and captures full UI Mode traces for debugging.

This guide covers Svelte 4 and Svelte 5 (with runes). Examples use TypeScript and Playwright 1.49+.

For React patterns, see Playwright Component Testing for React. For Vue, Playwright Component Testing Vue Complete Guide. The playwright-e2e skill automates these patterns in AI-generated tests.

Installation

pnpm create playwright --ct
# select Svelte when prompted

Scaffolded files:

FilePurpose
playwright-ct.config.tsCT-specific config
playwright/index.htmlVite shell
playwright/index.tsOptional global providers
tests/component/example.spec.tsStarter spec

The CT config

import { defineConfig, devices } from '@playwright/experimental-ct-svelte';
import { resolve } from 'path';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  testDir: './tests/component',
  fullyParallel: true,
  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
    ctViteConfig: {
      plugins: [svelte()],
      resolve: {
        alias: {
          $lib: resolve(__dirname, './src/lib'),
        },
      },
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

The plugin registration is required because the CT adapter does not auto-detect Svelte plugins from your real Vite config.

Your first Svelte component test

<!-- src/lib/Counter.svelte -->
<script lang="ts">
  export let initial = 0;
  let count = initial;
  function increment() {
    count += 1;
  }
</script>

<div>
  <p>Count: {count}</p>
  <button on:click={increment} aria-label="Increment">+</button>
</div>
import { test, expect } from '@playwright/experimental-ct-svelte';
import Counter from '../../src/lib/Counter.svelte';

test('renders initial value', async ({ mount }) => {
  const component = await mount(Counter, { props: { initial: 5 } });
  await expect(component.getByText('Count: 5')).toBeVisible();
});

test('increments on click', async ({ mount }) => {
  const component = await mount(Counter);
  await component.getByRole('button', { name: 'Increment' }).click();
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByText('Count: 2')).toBeVisible();
});

Svelte 5 runes

For Svelte 5 components using $state and $props, the API is identical at the test level. The compiler handles the rune translation.

<!-- src/lib/CounterRunes.svelte (Svelte 5) -->
<script lang="ts">
  let { initial = 0 } = $props();
  let count = $state(initial);
</script>

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

Tests do not change; you mount and assert exactly as before.

Event handling

Svelte's custom events bubble to the host. Capture them via the on option.

<!-- src/lib/RangeInput.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  export let value = 0;
  const dispatch = createEventDispatcher<{ change: { value: number } }>();
  function handle(e: Event) {
    const next = Number((e.target as HTMLInputElement).value);
    value = next;
    dispatch('change', { value: next });
  }
</script>

<input type="range" min="0" max="100" {value} on:input={handle} aria-label="Range" />
test('dispatches change event', async ({ mount }) => {
  const events: number[] = [];
  const component = await mount(RangeInput, {
    on: {
      change: (e: CustomEvent<{ value: number }>) => events.push(e.detail.value),
    },
  });
  await component.getByLabel('Range').fill('42');
  expect(events).toEqual([42]);
});

Slots

Slots accept render functions or strings.

<!-- src/lib/Modal.svelte -->
<script lang="ts">
  export let open = false;
</script>

{#if open}
  <div role="dialog" aria-modal="true">
    <slot name="title" />
    <slot />
    <slot name="footer" />
  </div>
{/if}
test('renders slot content', async ({ mount }) => {
  const component = await mount(Modal, {
    props: { open: true },
    slots: {
      title: '<h2>Confirm</h2>',
      default: '<p>Are you sure?</p>',
      footer: '<button aria-label="Close">Cancel</button>',
    },
  });
  await expect(component.getByRole('heading', { name: 'Confirm' })).toBeVisible();
  await expect(component.getByText('Are you sure?')).toBeVisible();
  await expect(component.getByRole('button', { name: 'Close' })).toBeVisible();
});

Stores

Svelte stores are subscribed-to at the component level. Provide a value via context or mount.

import { writable } from 'svelte/store';

test('renders user from context store', async ({ mount }) => {
  const user = writable({ name: 'Asha Patel', role: 'admin' });
  const component = await mount(UserBadge, {
    hooksConfig: { user },
  });
  await expect(component.getByText('Asha Patel')).toBeVisible();
});

Wire context in playwright/index.ts:

import { beforeMount } from '@playwright/experimental-ct-svelte/hooks';

export type HooksConfig = {
  user?: import('svelte/store').Writable<{ name: string; role: string }>;
};

beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
  if (hooksConfig?.user) {
    // Pass via setContext or wrap App in a provider component
  }
  return App;
});

Mocking network

Same page.route API as everywhere else in Playwright.

test('user list renders mocked users', async ({ mount, page }) => {
  await page.route('**/api/users', (route) =>
    route.fulfill({
      json: [
        { id: 1, name: 'Ada' },
        { id: 2, name: 'Grace' },
      ],
    })
  );

  const component = await mount(UserList);
  await expect(component.getByRole('listitem')).toHaveCount(2);
});

Fixtures

Add typed fixtures for shared setup.

import { test as base, expect } from '@playwright/experimental-ct-svelte';

type Fixtures = {
  adminUser: { name: string; role: 'admin' };
};

export const test = base.extend<Fixtures>({
  adminUser: async ({}, use) => {
    await use({ name: 'Admin Alex', role: 'admin' });
  },
});

export { expect };

Visual snapshots

test('counter visual baseline', async ({ mount }) => {
  const component = await mount(Counter, { props: { initial: 42 } });
  await expect(component).toHaveScreenshot('counter-42.png');
});

See Playwright Visual Comparison Snapshots Guide for full snapshot configuration.

Forms and input

<script lang="ts">
  let email = '';
</script>

<form>
  <label>
    Email
    <input bind:value={email} aria-describedby="email-help" />
  </label>
  <p id="email-help">We never share your email.</p>
  <p>You typed: {email}</p>
</form>
test('echoes typed email', async ({ mount }) => {
  const component = await mount(EmailForm);
  await component.getByLabel('Email').fill('asha@example.com');
  await expect(component.getByText('You typed: asha@example.com')).toBeVisible();
});

SvelteKit considerations

Component tests run components, not full SvelteKit pages. For pages with +page.server.ts data loading, you must mock the load function or pass props directly.

test('page renders with mocked data', async ({ mount }) => {
  const component = await mount(PageContent, {
    props: {
      data: { items: [{ id: 1, name: 'Item' }] },
    },
  });
  await expect(component.getByRole('listitem')).toHaveCount(1);
});

For full-page SvelteKit tests with routing, use end-to-end tests instead.

Migrating from @testing-library/svelte

Testing LibraryPlaywright CT
render(Component, { props })mount(Component, { props })
screen.getByRole(...)component.getByRole(...)
fireEvent.click(button)component.getByRole('button').click()
waitFor(() => ...)expect(locator).toBeVisible()
act()Not needed

Common pitfalls

Pitfall 1: tick() confusion. Svelte's micro-task scheduler updates the DOM after tick(). Playwright's auto-waiting locators handle this, so you rarely need tick directly.

Pitfall 2: SSR-only components. Components designed for server-side rendering may rely on onMount to populate state. They mount blank in CT until the lifecycle fires.

Pitfall 3: Two-way binding tests. bind:value updates synchronously but assertions still need auto-waiting.

Pitfall 4: Transitions and animations. Svelte's built-in transitions can produce diff churn. Disable with use: { reducedMotion: 'reduce' }.

Pitfall 5: Component import paths. Svelte components must be imported with the .svelte extension; TypeScript types are inferred.

Anti-patterns

  • Mounting full pages instead of components. Use e2e tests for pages.
  • Asserting on internal state. Test what the user sees.
  • Skipping the picker. UI Mode regenerates correct locators in two clicks.
  • Mixing CT and SvelteKit's own vitest tests in one file. Keep them separate.

CI integration

Add to your CI pipeline alongside e2e tests:

- name: Component tests
  run: pnpm test-ct
- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: ct-traces
    path: test-results

For full CI scaffolding, see Playwright CI GitHub Actions Complete Guide.

Conclusion and next steps

Playwright Component Testing for Svelte brings real browsers to your component layer with minimal setup. Use it for design system components, complex widgets, and visual regression. Pair with end-to-end tests for full-page flows.

Install the playwright-e2e skill so AI assistants generate Svelte CT tests that follow these patterns. For React, see Playwright Component Testing for React; for Vue, Playwright Component Testing Vue Complete Guide.

Playwright Component Testing for Svelte: Complete 2026 Guide | QASkills.sh