Migration Guide

A comprehensive, step-by-step guide for migrating your Svelte testing setup from @testing-library/svelte to vitest-browser-svelte, based on real-world migration experience and current best practices.

🎯 Why Migrate to vitest-browser-svelte?

  • Real Browser Environment: Tests run in actual Playwright browsers instead of jsdom simulation
  • Better Svelte 5 Support: Native support for runes, snippets, and modern Svelte patterns
  • Auto-retry Logic: Built-in element waiting and retrying eliminates flaky tests
  • Client-Server Alignment: Enables testing with real FormData and Request objects for better integration confidence
  • Future-Proof: Official Svelte team recommendation for modern testing

πŸ“‹ Migration Strategy

This guide follows a proven Foundation First approach that supports the Client-Server Alignment Strategy:

  1. Phase 1: Environment setup and configuration
  2. Phase 2: Core pattern migration (one test file at a time)
  3. Phase 3: Advanced patterns and server testing alignment
  4. Phase 4: Cleanup and validation

πŸš€ Phase 1: Environment Setup

Step 1: Update Dependencies

# Install vitest-browser-svelte and related packages
pnpm add -D @vitest/browser vitest-browser-svelte playwright

# Remove old testing library dependencies
pnpm remove @testing-library/svelte @testing-library/jest-dom jsdom

Step 2: Update Vitest Configuration

Replace your existing test configuration with browser mode:

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [sveltekit(), tailwindcss()],

	test: {
		projects: [
			{
				// Client-side tests (Svelte components)
				extends: './vite.config.ts',
				test: {
					name: 'client',
					environment: 'browser',
					// Timeout for browser tests - prevent hanging on element lookups
					testTimeout: 2000,
					browser: {
						enabled: true,
						provider: 'playwright',
						instances: [
							{ browser: 'chromium' },
							// { browser: 'firefox' },
							// { browser: 'webkit' },
						],
					},
					include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
					exclude: [
						'src/lib/server/**',
						'src/**/*.ssr.{test,spec}.{js,ts}',
					],
					setupFiles: ['./vitest-setup-client.ts'],
				},
			},
			{
				// SSR tests (Server-side rendering)
				extends: './vite.config.ts',
				test: {
					name: 'ssr',
					environment: 'node',
					include: ['src/**/*.ssr.{test,spec}.{js,ts}'],
				},
			},
			{
				// Server-side tests (Node.js utilities)
				extends: './vite.config.ts',
				test: {
					name: 'server',
					environment: 'node',
					include: ['src/**/*.{test,spec}.{js,ts}'],
					exclude: [
						'src/**/*.svelte.{test,spec}.{js,ts}',
						'src/**/*.ssr.{test,spec}.{js,ts}',
					],
				},
			},
		],
	},
});

Step 3: Update Setup Files

Remove jsdom-specific polyfills in vitest-setup-client.ts since you’re now using real browsers:

// BEFORE: vitest-setup-client.ts (remove these)
import '@testing-library/jest-dom';

// Mock matchMedia for jsdom
Object.defineProperty(window, 'matchMedia', {
	writable: true,
	value: vi.fn().mockImplementation((query) => ({
		matches: false,
		media: query,
		// ... more jsdom polyfills
	})),
});

// AFTER: vitest-setup-client.ts (minimal setup)
/// <reference types="@vitest/browser/matchers" />
/// <reference types="@vitest/browser/providers/playwright" />

πŸ§ͺ Phase 2: Core Pattern Migration

Essential Import Changes

// BEFORE: @testing-library/svelte
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';

// AFTER: vitest-browser-svelte
import { render } from 'vitest-browser-svelte';
import { page } from '@vitest/browser/context';

Critical Pattern: Always Use Locators

// ❌ NEVER use containers - no auto-retry, manual DOM queries
const { container } = render(MyComponent);
const button = container.querySelector('[data-testid="submit"]');

// βœ… ALWAYS use locators - auto-retry, semantic queries
render(MyComponent);
const button = page.getByTestId('submit');
await button.click(); // Automatic waiting and retrying

Component Rendering Migration

// BEFORE: @testing-library/svelte
test('button renders with correct variant', () => {
	render(Button, { variant: 'primary' });
	const button = screen.getByRole('button');
	expect(button).toBeInTheDocument();
	expect(button).toHaveClass('btn-primary');
});

// AFTER: vitest-browser-svelte
test('button renders with correct variant', async () => {
	render(Button, { variant: 'primary' });
	const button = page.getByRole('button');
	await expect.element(button).toBeInTheDocument();
	await expect.element(button).toHaveClass('btn-primary');
});

User Interaction Migration

// BEFORE: @testing-library/svelte
test('form submission', async () => {
	const user = userEvent.setup();
	render(LoginForm);

	const email_input = screen.getByLabelText('Email');
	const password_input = screen.getByLabelText('Password');
	const submit_button = screen.getByRole('button', { name: 'Login' });

	await user.type(email_input, '[email protected]');
	await user.type(password_input, 'password');
	await user.click(submit_button);

	expect(screen.getByText('Welcome!')).toBeInTheDocument();
});

// AFTER: vitest-browser-svelte
test('form submission', async () => {
	render(LoginForm);

	const email_input = page.getByLabelText('Email');
	const password_input = page.getByLabelText('Password');
	const submit_button = page.getByRole('button', { name: 'Login' });

	await email_input.fill('[email protected]');
	await password_input.fill('password');
	await submit_button.click();

	await expect
		.element(page.getByText('Welcome!'))
		.toBeInTheDocument();
});

Event Handler Testing

// BEFORE: @testing-library/svelte
test('click handler', async () => {
	const handle_click = vi.fn();
	render(Button, { onClick: handle_click });

	await userEvent.click(screen.getByRole('button'));
	expect(handle_click).toHaveBeenCalled();
});

// AFTER: vitest-browser-svelte
test('click handler', async () => {
	const handle_click = vi.fn();
	render(Button, { onclick: handle_click });

	await page.getByRole('button').click();
	expect(handle_click).toHaveBeenCalled();
});

πŸ”„ Key Migration Transformations

1. Query Transformations

@testing-library/sveltevitest-browser-svelte
screen.getByRole('button')page.getByRole('button')
screen.getByText('Hello')page.getByText('Hello')
screen.getByTestId('submit')page.getByTestId('submit')
screen.getByLabelText('Email')page.getByLabelText('Email')

2. Assertion Transformations

@testing-library/sveltevitest-browser-svelte
expect(element).toBeInTheDocument()await expect.element(element).toBeInTheDocument()
expect(element).toHaveClass('btn')await expect.element(element).toHaveClass('btn')
expect(element).toHaveTextContent('Hi')await expect.element(element).toHaveTextContent('Hi')
expect(element).toBeVisible()await expect.element(element).toBeVisible()

3. Event Handling Transformations

@testing-library/sveltevitest-browser-svelte
await fireEvent.click(button)await button.click()
await fireEvent.change(input, { target: { value: 'test' } })await input.fill('test')
await fireEvent.keyDown(element, { key: 'Enter' })await userEvent.keyboard('{Enter}')
await fireEvent.focus(input)await input.focus()

🎯 Phase 3: Advanced Patterns

Svelte 5 Runes Testing

// Testing components with Svelte 5 runes
import { render } from 'vitest-browser-svelte';
import { page } from '@vitest/browser/context';
import { untrack } from 'svelte';

test('counter with runes', async () => {
	let count = $state(0);
	let doubled = $derived(count * 2);

	render(Counter, { initial_count: 5 });

	const count_display = page.getByTestId('count');
	await expect.element(count_display).toHaveTextContent('5');

	const increment_button = page.getByRole('button', {
		name: 'Increment',
	});
	await increment_button.click();

	await expect.element(count_display).toHaveTextContent('6');

	// Test derived values with untrack
	expect(untrack(() => doubled)).toBe(12);
});

Form Validation Lifecycle Testing

// Test the full lifecycle: valid β†’ validate β†’ invalid β†’ fix β†’ valid
test('form validation lifecycle', async () => {
	render(LoginForm);

	const email_input = page.getByLabelText('Email');
	const submit_button = page.getByRole('button', { name: 'Submit' });

	// Initially valid (no validation triggered)
	await expect.element(submit_button).toBeEnabled();

	// Trigger validation with invalid data
	await email_input.fill('invalid-email');
	await submit_button.click({ force: true });

	// Now invalid with error message
	const error_message = page.getByText(
		'Please enter a valid email address',
	);
	await expect.element(error_message).toBeVisible();

	// Fix the error
	await email_input.fill('[email protected]');
	await submit_button.click();

	// Back to valid state
	await expect.element(error_message).not.toBeVisible();
});

Handling Strict Mode Violations

// ❌ FAILS: Multiple elements match
test('navigation links', async () => {
	render(Navigation);
	const home_link = page.getByRole('link', { name: 'Home' }); // Error!
});

// βœ… CORRECT: Use .first(), .nth(), .last()
test('navigation links', async () => {
	render(Navigation);
	const home_link = page.getByRole('link', { name: 'Home' }).first();
	await expect.element(home_link).toBeVisible();
});

Component Dependencies and Mocking

// Mock utility functions with realistic return values
vi.mock('$lib/utils/validation', () => ({
	validate_email: vi.fn(() => ({ valid: true, message: '' })),
	validate_password: vi.fn(() => ({ valid: true, message: '' })),
}));

test('form uses validation utilities', async () => {
	const mock_validate_email = vi.mocked(validate_email);

	render(LoginForm);

	const email_input = page.getByLabelText('Email');
	await email_input.fill('[email protected]');

	expect(mock_validate_email).toHaveBeenCalledWith(
		'[email protected]',
	);
});

🚨 Common Migration Pitfalls

1. Locator vs Matcher Confusion

// ❌ WRONG: Using locators as matchers
await expect(page.getByRole('button')).toBeInTheDocument();

// βœ… CORRECT: Use expect.element() for locators
await expect.element(page.getByRole('button')).toBeInTheDocument();

// βœ… CORRECT: Use regular expect for values
expect(some_value).toBe(true);

2. Async Assertions Required

// ❌ OLD: Sync assertions
expect(element).toBeInTheDocument();

// βœ… NEW: Async assertions with auto-retry
await expect.element(element).toBeInTheDocument();

3. No More Manual Waiting

// ❌ OLD: Manual waiting with @testing-library/svelte
import { waitFor } from '@testing-library/dom';

await waitFor(() => {
	expect(screen.getByText('Success')).toBeInTheDocument();
});

// βœ… NEW: Built-in retry with vitest-browser-svelte
await expect.element(page.getByText('Success')).toBeInTheDocument();

4. Animation and Transition Issues

// ❌ Can cause hangs - avoid clicking submit buttons with SvelteKit enhance
await submit_button.click(); // May cause SSR errors!

// βœ… Test form state directly or use force: true
await submit_button.click({ force: true });

// βœ… Or test validation state instead
render(MyForm, { errors: { email: 'Required' } });
await expect.element(page.getByText('Required')).toBeInTheDocument();

πŸ”§ Phase 4: Cleanup and Validation

Update Package Scripts

{
	"scripts": {
		"test:unit": "vitest",
		"test:server": "vitest --project=server",
		"test:client": "vitest --project=client",
		"test:ssr": "vitest --project=ssr",
		"test": "npm run test:unit -- --run && npm run test:e2e",
		"test:e2e": "playwright test"
	}
}

Migration Checklist

  • Dependencies: Removed @testing-library/svelte, installed vitest-browser-svelte
  • Configuration: Updated vite.config.ts for browser mode
  • Imports: Changed render import and added page import
  • Queries: Replaced screen.getBywith page.getBy
  • Interactions: Replaced userEvent with direct element methods
  • Assertions: Added await before expect.element()
  • Mocks: Removed browser API mocks (they work natively now)
  • Animation: Added Element.animate mock for Svelte 5
  • Tests: Updated all test files with new patterns
  • CI/CD: Updated test scripts and pipeline configuration

πŸ”— Migration Resources


This migration guide represents a significant improvement in testing capabilities for Svelte applications, providing better developer experience and more reliable tests through real browser environments.