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.

Projects Using Vite+

Sveltest uses Vite+ for development, builds, tests, linting, and packaging. Keep the Vite core alias, Vitest, browser provider, and coverage package aligned with its bundled versions. Upgrade them through the toolchain migration, not as independent packages.

In a Vite+ project, import configuration from vite-plus, test APIs from vite-plus/test, and browser locators from vite-plus/test/browser. Keep build and test configuration together in vite.config.ts. The renderer remains vitest-browser-svelte.

The examples below retain standard Vitest imports for portability with the official Svelte CLI scaffold.

Run vp migrate when upgrading, review its changes, and preserve the project’s dependency security policies. Overrides do not install missing peers: keep the package-local dependencies it provisions. See the Vite+ migration guide.

Coverage should include executable source files, not HTML templates or Markdown guides.

🎯 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

1# Install vitest-browser-svelte and matching Vitest browser packages
2pnpm add -D @vitest/browser-playwright vitest-browser-svelte playwright
3
4# Remove old testing library dependencies
5pnpm remove @testing-library/svelte @testing-library/jest-dom jsdom

Step 2: Update Vitest Configuration

For a new project, use sv add vitest="usages:unit,component" playwright instead; the CLI already provides browser testing. For a migration, the configuration below follows that baseline with Sveltest’s optional SSR project added. Keep SSR files excluded from server so they run only once.

1// vite.config.ts
2import tailwindcss from '@tailwindcss/vite';
3import { sveltekit } from '@sveltejs/kit/vite';
4import { playwright } from '@vitest/browser-playwright';
5import { defineConfig } from 'vitest/config';
6
7export default defineConfig({
8	plugins: [tailwindcss(), sveltekit()],
9
10	test: {
11		expect: { requireAssertions: true },
12		projects: [
13			{
14				// Client-side tests (Svelte components)
15				extends: './vite.config.ts',
16				test: {
17					name: 'client',
18					browser: {
19						enabled: true,
20						provider: playwright(),
21						instances: [{ browser: 'chromium', headless: true }],
22					},
23					include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
24					exclude: ['src/lib/server/**'],
25				},
26			},
27			{
28				// SSR tests (Server-side rendering)
29				extends: './vite.config.ts',
30				test: {
31					name: 'ssr',
32					environment: 'node',
33					include: ['src/**/*.ssr.{test,spec}.{js,ts}'],
34				},
35			},
36			{
37				// Server-side tests (Node.js utilities)
38				extends: './vite.config.ts',
39				test: {
40					name: 'server',
41					environment: 'node',
42					include: ['src/**/*.{test,spec}.{js,ts}'],
43					exclude: [
44						'src/**/*.svelte.{test,spec}.{js,ts}',
45						'src/**/*.ssr.{test,spec}.{js,ts}',
46					],
47				},
48			},
49		],
50	},
51});

Step 3: Remove jsdom Setup Files

Remove jsdom-specific polyfills such as @testing-library/jest-dom, matchMedia mocks, and obsolete type-reference setup files. Importing render from vitest-browser-svelte registers cleanup automatically; no setup file is required for that pattern. With renderer version 3, await render(...). Only add setupFiles: ['vitest-browser-svelte'] to the client project if you use page.render(...) without importing the renderer.

If TypeScript does not pick up browser render/assertion types, add the package to tsconfig.json:

1{
2	"compilerOptions": {
3		"types": ["vitest-browser-svelte"]
4	}
5}

Step 4: Colocate Playwright Tests

Use testMatch: '**/*.e2e.{ts,js}' in playwright.config.ts instead of testDir: 'e2e'. Move tests beside their routes and rename them from *.spec.ts to *.e2e.ts, for example src/routes/contact/page.svelte.e2e.ts. Update relative imports and any CI file filters when moving files. Vitest’s .test/.spec patterns will not collect .e2e files. Exclude **/*.e2e.{js,ts} from coverage if you collect all source files.

πŸ§ͺ Phase 2: Core Pattern Migration

Essential Import Changes

1// BEFORE: @testing-library/svelte
2import { render, screen } from '@testing-library/svelte';
3import userEvent from '@testing-library/user-event';
4
5// AFTER: vitest-browser-svelte
6import { render } from 'vitest-browser-svelte';
7import { page } from 'vitest/browser';

Critical Pattern: Always Use Locators

1// ❌ NEVER use containers - no auto-retry, manual DOM queries
2const { container } = render(MyComponent);
3const button = container.querySelector('[data-testid="submit"]');
4
5// βœ… ALWAYS use locators - auto-retry, semantic queries
6await render(MyComponent);
7const button = page.getByTestId('submit');
8await button.click(); // Automatic waiting and retrying

Component Rendering Migration

1// BEFORE: @testing-library/svelte
2test('button renders with correct variant', () => {
3	render(Button, { variant: 'primary' });
4	const button = screen.getByRole('button');
5	expect(button).toBeInTheDocument();
6	expect(button).toHaveClass('btn-primary');
7});
8
9// AFTER: vitest-browser-svelte
10test('button renders with correct variant', async () => {
11	await render(Button, { variant: 'primary' });
12	const button = page.getByRole('button');
13	await expect.element(button).toBeInTheDocument();
14	await expect.element(button).toHaveClass('btn-primary');
15});

User Interaction Migration

1// BEFORE: @testing-library/svelte
2test('form submission', async () => {
3	const user = userEvent.setup();
4	render(LoginForm);
5
6	const email_input = screen.getByLabelText('Email');
7	const password_input = screen.getByLabelText('Password');
8	const submit_button = screen.getByRole('button', { name: 'Login' });
9
10	await user.type(email_input, '[email protected]');
11	await user.type(password_input, 'password');
12	await user.click(submit_button);
13
14	expect(screen.getByText('Welcome!')).toBeInTheDocument();
15});
16
17// AFTER: vitest-browser-svelte
18test('form submission', async () => {
19	await render(LoginForm);
20
21	const email_input = page.getByLabelText('Email');
22	const password_input = page.getByLabelText('Password');
23	const submit_button = page.getByRole('button', { name: 'Login' });
24
25	await email_input.fill('[email protected]');
26	await password_input.fill('password');
27	await submit_button.click();
28
29	await expect
30		.element(page.getByText('Welcome!'))
31		.toBeInTheDocument();
32});

Event Handler Testing

1// BEFORE: @testing-library/svelte
2test('click handler', async () => {
3	const handle_click = vi.fn();
4	render(Button, { onClick: handle_click });
5
6	await userEvent.click(screen.getByRole('button'));
7	expect(handle_click).toHaveBeenCalled();
8});
9
10// AFTER: vitest-browser-svelte
11test('click handler', async () => {
12	const handle_click = vi.fn();
13	await render(Button, { onclick: handle_click });
14
15	await page.getByRole('button').click();
16	expect(handle_click).toHaveBeenCalled();
17});

πŸ”„ 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.element().focus()

🎯 Phase 3: Advanced Patterns

Svelte 5 Runes Testing

1// Testing components with Svelte 5 runes
2import { render } from 'vitest-browser-svelte';
3import { page } from 'vitest/browser';
4import { untrack } from 'svelte';
5
6test('counter with runes', async () => {
7	let count = $state(0);
8	let doubled = $derived(count * 2);
9
10	await render(Counter, { initial_count: 5 });
11
12	const count_display = page.getByTestId('count');
13	await expect.element(count_display).toHaveTextContent('5');
14
15	const increment_button = page.getByRole('button', {
16		name: 'Increment',
17	});
18	await increment_button.click();
19
20	await expect.element(count_display).toHaveTextContent('6');
21
22	// Test derived values with untrack
23	expect(untrack(() => doubled)).toBe(12);
24});

Form Validation Lifecycle Testing

1// Test the full lifecycle: valid β†’ validate β†’ invalid β†’ fix β†’ valid
2test('form validation lifecycle', async () => {
3	await render(LoginForm);
4
5	const email_input = page.getByLabelText('Email');
6	const submit_button = page.getByRole('button', { name: 'Submit' });
7
8	// Initially valid (no validation triggered)
9	await expect.element(submit_button).toBeEnabled();
10
11	// Trigger validation with invalid data
12	await email_input.fill('invalid-email');
13	await submit_button.click({ force: true });
14
15	// Now invalid with error message
16	const error_message = page.getByText(
17		'Please enter a valid email address',
18	);
19	await expect.element(error_message).toBeVisible();
20
21	// Fix the error
22	await email_input.fill('[email protected]');
23	await submit_button.click();
24
25	// Back to valid state
26	await expect.element(error_message).not.toBeVisible();
27});

Handling Strict Mode Violations

1// ❌ FAILS: Multiple elements match
2test('navigation links', async () => {
3	render(Navigation);
4	const home_link = page.getByRole('link', { name: 'Home' }); // Error!
5});
6
7// βœ… CORRECT: Use .first(), .nth(), .last()
8test('navigation links', async () => {
9	await render(Navigation);
10	const home_link = page.getByRole('link', { name: 'Home' }).first();
11	await expect.element(home_link).toBeVisible();
12});

Component Dependencies and Mocking

1// Mock utility functions with realistic return values
2vi.mock('#lib/utils/validation.js', () => ({
3	validate_email: vi.fn(() => ({ valid: true, message: '' })),
4	validate_password: vi.fn(() => ({ valid: true, message: '' })),
5}));
6
7test('form uses validation utilities', async () => {
8	const mock_validate_email = vi.mocked(validate_email);
9
10	await render(LoginForm);
11
12	const email_input = page.getByLabelText('Email');
13	await email_input.fill('[email protected]');
14
15	expect(mock_validate_email).toHaveBeenCalledWith(
16		'[email protected]',
17	);
18});

🚨 Common Migration Pitfalls

1. Locator vs Matcher Confusion

1// ❌ WRONG: Using locators as matchers
2await expect(page.getByRole('button')).toBeInTheDocument();
3
4// βœ… CORRECT: Use expect.element() for locators
5await expect.element(page.getByRole('button')).toBeInTheDocument();
6
7// βœ… CORRECT: Use regular expect for values
8expect(some_value).toBe(true);

2. Async Assertions Required

1// ❌ OLD: Sync assertions
2expect(element).toBeInTheDocument();
3
4// βœ… NEW: Async assertions with auto-retry
5await expect.element(element).toBeInTheDocument();

3. No More Manual Waiting

1// ❌ OLD: Manual waiting with @testing-library/svelte
2import { waitFor } from '@testing-library/dom';
3
4await waitFor(() => {
5	expect(screen.getByText('Success')).toBeInTheDocument();
6});
7
8// βœ… NEW: Built-in retry with vitest-browser-svelte
9await expect.element(page.getByText('Success')).toBeInTheDocument();

4. Animation and Transition Issues

1// ❌ Can cause hangs - avoid clicking submit buttons with SvelteKit enhance
2await submit_button.click(); // May cause SSR errors!
3
4// βœ… Test form state directly or use force: true
5await submit_button.click({ force: true });
6
7// βœ… Or test validation state instead
8await render(MyForm, { errors: { email: 'Required' } });
9await expect.element(page.getByText('Required')).toBeInTheDocument();

πŸ”§ Phase 4: Cleanup and Validation

Update Package Scripts

1{
2	"scripts": {
3		"test:unit": "vitest",
4		"test:server": "vitest --project=server",
5		"test:client": "vitest --project=client",
6		"test:ssr": "vitest --project=ssr",
7		"test": "npm run test:unit -- --run && npm run test:e2e",
8		"test:e2e": "playwright test"
9	}
10}

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.