Getting Started

Sveltest is a reference guide and example project for testing Svelte and SvelteKit. Its baseline follows the official Svelte CLI: Vitest for unit and browser component tests, plus Playwright for E2E tests. This is the CLI default, not the only supported testing approach.

Set Up Your Project

1pnpm dlx sv@latest create my-testing-app
2cd my-testing-app
3pnpm dlx sv@latest add vitest="usages:unit,component" playwright
4pnpm exec playwright install chromium

You can also select these add-ons during project creation. Choose both unit and component testing for Vitest. The CLI installs Vitest, @vitest/browser-playwright, vitest-browser-svelte, and Playwright. A fresh scaffold does not need a Testing Library or jsdom migration. For an existing setup, see the migration guide.

Official references: Vitest add-on and Playwright add-on.

Vitest Configuration

The generated configuration separates tests by filename. Keep your existing application plugins; the important part is test:

1// vite.config.ts
2import { sveltekit } from '@sveltejs/kit/vite';
3import { playwright } from '@vitest/browser-playwright';
4import { defineConfig } from 'vitest/config';
5
6export default defineConfig({
7	plugins: [sveltekit()],
8	test: {
9		expect: { requireAssertions: true },
10		projects: [
11			{
12				extends: './vite.config.ts',
13				test: {
14					name: 'client',
15					browser: {
16						enabled: true,
17						provider: playwright(),
18						instances: [{ browser: 'chromium', headless: true }],
19					},
20					include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
21					exclude: ['src/lib/server/**'],
22				},
23			},
24			{
25				extends: './vite.config.ts',
26				test: {
27					name: 'server',
28					environment: 'node',
29					include: ['src/**/*.{test,spec}.{js,ts}'],
30					exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
31				},
32			},
33		],
34	},
35});

Importing render from vitest-browser-svelte registers automatic cleanup; this pattern does not require setupFiles. Sveltest uses version 3 of the renderer, so its examples use await render(...). If you use page.render(...) instead of importing render, register vitest-browser-svelte in the client project’s setupFiles.

If TypeScript cannot find the renderer’s browser types, add vitest-browser-svelte to your existing compilerOptions.types.

Colocate Every Test Type

1src/
2├── lib/components/
3│   ├── button.svelte
4│   ├── button.svelte.test.ts
5│   └── button.ssr.test.ts
6└── routes/
7    ├── +page.svelte
8    ├── +page.server.ts
9    ├── page.svelte.test.ts
10    ├── page.server.test.ts
11    ├── page.ssr.test.ts
12    └── page.svelte.e2e.ts
FilenameRunnerPurpose
*.svelte.test.tsVitest clientComponents in real Chromium
*.test.ts excluding component testsVitest serverUtilities, load functions, actions, endpoints
*.ssr.test.tsVitest Node; optional separate SSR projectServer-rendered HTML
*.e2e.tsPlaywrightComplete application workflows

Vitest also accepts .spec.ts and JavaScript equivalents. Playwright uses .e2e.ts or .e2e.js, not .spec.ts in this setup, so neither runner collects the other’s tests. Colocation is supported, not newly required. Put cross-route journeys at the nearest shared route directory; no separate e2e/ directory is needed.

Playwright Configuration

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});

See E2E testing for full browser workflows.

Optional SSR Project

The CLI scaffold has two Vitest projects, client and server. Sveltest adds ssr to run rendering tests independently. Add its include pattern and exclude those files from server to avoid duplicate runs; see SSR testing for the configuration.

Sveltest uses Vite+ for development, builds, tests, linting, formatting, and CLI packaging. Its Vite core alias and Vitest dependencies are aligned with the bundled toolchain. Repository tests import from vite-plus/test and vite-plus/test/browser; the guide examples keep standard Vitest imports so they work with the official Svelte CLI scaffold. See Vite+ migration notes.

Our headless browser tests disable the runner UI to avoid scaled click targets. This and the temporary browser runner workaround are repository-specific, not scaffold requirements.

Run Tests

1pnpm test:unit --run
2pnpm test:e2e

In this repository, pnpm test:ssr --run runs the optional SSR project.

Understanding the Client-Server Alignment Strategy

Before diving into component testing, it’s important to understand the Client-Server Alignment Strategy that guides this testing approach:

The Four-Layer Approach

  1. Shared Validation Logic: Use the same validation functions on both client and server
  2. Real FormData/Request Objects: Server tests use real web APIs, not mocks
  3. TypeScript Contracts: Shared interfaces catch mismatches at compile time
  4. E2E Tests: Final safety net for complete integration validation

Why This Matters

Traditional testing with heavy mocking can pass while production fails due to client-server mismatches. This strategy ensures your tests catch real integration issues:

1// ❌ BRITTLE: Heavy mocking hides real issues
2const mock_request = { formData: vi.fn().mockResolvedValue(...) };
3
4// ✅ ROBUST: Real FormData catches field name mismatches
5const form_data = new FormData();
6form_data.append('email', 'user@example.com');
7const request = new Request('http://localhost/api/register', {
8	method: 'POST',
9	body: form_data,
10});

This multi-project Vitest setup supports this strategy by keeping client, server, and SSR tests separate while maintaining shared validation logic.

Write Your First Test

Let’s create a simple button component and test it step-by-step in your own project.

Step 1: Create a Simple Component

Create src/lib/components/my-button.svelte:

1<script lang="ts">
2	interface Props {
3		variant?: 'primary' | 'secondary';
4		disabled?: boolean;
5		onclick?: () => void;
6		children: any;
7	}
8
9	let {
10		variant = 'primary',
11		disabled = false,
12		onclick,
13		children,
14	}: Props = $props();
15</script>
16
17<button
18	class="btn btn-{variant}"
19	{disabled}
20	{onclick}
21	data-testid="my-button"
22>
23	{@render children()}
24</button>

Step 2: Write Your First Test

Create src/lib/components/my-button.svelte.test.ts:

1import { describe, expect, it, vi } from 'vitest';
2import { render } from 'vitest-browser-svelte';
3import { page } from 'vitest/browser';
4import { createRawSnippet } from 'svelte';
5import MyButton from './my-button.svelte';
6
7describe('MyButton', () => {
8	it('should render with correct text', async () => {
9		const children = createRawSnippet(() => ({
10			render: () => `<span>Click me</span>`,
11		}));
12
13		await render(MyButton, { children });
14
15		const button = page.getByRole('button', { name: 'Click me' });
16		await expect.element(button).toBeInTheDocument();
17	});
18
19	it('should handle click events', async () => {
20		const click_handler = vi.fn();
21		const children = createRawSnippet(() => ({
22			render: () => `<span>Click me</span>`,
23		}));
24
25		await render(MyButton, { onclick: click_handler, children });
26
27		const button = page.getByRole('button', { name: 'Click me' });
28		await button.click();
29
30		expect(click_handler).toHaveBeenCalledOnce();
31	});
32
33	it('should apply correct variant class', async () => {
34		const children = createRawSnippet(() => ({
35			render: () => `<span>Secondary</span>`,
36		}));
37
38		await render(MyButton, { variant: 'secondary', children });
39
40		const button = page.getByTestId('my-button');
41		await expect.element(button).toHaveClass('btn-secondary');
42	});
43});

Time to test it out!

Step 3: Run Your Test

If you already have pnpm run test:unit running it should update in watch mode!

You can test on a component basis too, this is handy if you have a lot of tests and want to isolate what you’re testing:

1# run once
2pnpm vitest run src/lib/components/my-button.svelte
3# use watch mode
4pnpm vitest src/lib/components/my-button.svelte

You should see all tests pass! 🎉

Understanding the Test Structure

Let’s break down what makes this test work with Vitest Browser Mode:

Essential Imports

1import { describe, expect, it, vi } from 'vitest'; // Test framework
2import { render } from 'vitest-browser-svelte'; // Svelte rendering
3import { page } from 'vitest/browser'; // Browser interactions
4import { createRawSnippet } from 'svelte'; // Svelte 5 snippets

The Golden Rule: Always Use Locators

Following the official Vitest Browser documentation, always use locators for reliable, auto-retrying queries:

1// ✅ DO: Use page locators (auto-retry, semantic)
2const button = page.getByRole('button', { name: 'Click me' });
3await button.click();
4
5// ❌ DON'T: Use containers (no auto-retry, manual queries)
6const { container } = render(MyButton);
7const button = container.querySelector('button');

Locator Hierarchy (Use in This Order)

Following Vitest Browser best practices:

  1. Semantic roles (best for accessibility):

    1page.getByRole('button', { name: 'Submit' });
    2page.getByRole('textbox', { name: 'Email' });
  2. Labels (good for forms):

    1page.getByLabel('Email address');
  3. Text content (good for unique text):

    1page.getByText('Welcome back');
  4. Test IDs (fallback for complex cases):

    1page.getByTestId('submit-button');

Critical: Handle Multiple Elements

Vitest Browser operates in strict mode - if multiple elements match, you’ll get an error:

1// ❌ FAILS: "strict mode violation" if multiple elements match
2page.getByRole('link', { name: 'Home' });
3
4// ✅ CORRECT: Use .first(), .nth(), .last() for multiple elements
5page.getByRole('link', { name: 'Home' }).first();
6page.getByRole('link', { name: 'Home' }).nth(1); // Second element (0-indexed)
7page.getByRole('link', { name: 'Home' }).last();

Common Patterns You’ll Use Daily

Testing Form Inputs

1it('should handle form input', async () => {
2	await render(MyInput, { label: 'Email', type: 'email' });
3
4	const input = page.getByLabel('Email');
5	await input.fill('user@example.com');
6
7	await expect.element(input).toHaveValue('user@example.com');
8});

Testing Conditional Rendering

1it('should show error message when invalid', async () => {
2	await render(MyInput, {
3		label: 'Email',
4		error: 'Invalid email format',
5	});
6
7	await expect
8		.element(page.getByText('Invalid email format'))
9		.toBeInTheDocument();
10});

Testing Loading States

1it('should show loading state', async () => {
2	const children = createRawSnippet(() => ({
3		render: () => `<span>Loading...</span>`,
4	}));
5
6	await render(MyButton, { loading: true, children });
7
8	await expect.element(page.getByRole('button')).toBeDisabled();
9	await expect
10		.element(page.getByText('Loading...'))
11		.toBeInTheDocument();
12});

Testing Svelte 5 Runes

Use untrack() when testing derived state:

1import { untrack, flushSync } from 'svelte';
2
3it('should handle reactive state', () => {
4	let count = $state(0);
5	let doubled = $derived(count * 2);
6
7	expect(untrack(() => doubled)).toBe(0);
8
9	count = 5;
10	flushSync(); // Ensure derived state updates
11	expect(untrack(() => doubled)).toBe(10);
12});

Quick Wins: Copy These Patterns

The Foundation First Template

This is Sveltest’s planning convention, not a Svelte CLI requirement. Keep unfinished cases skipped; every enabled test must assert a meaningful outcome because expect.requireAssertions is enabled.

1describe('ComponentName', () => {
2	describe('Initial Rendering', () => {
3		it.skip('should render with default props', async () => {
4			// Implement the test and add assertions before enabling it
5		});
6
7		it.skip('should render with all prop variants', async () => {
8			// TODO: Test different prop combinations
9		});
10	});
11
12	describe('User Interactions', () => {
13		it.skip('should handle click events', async () => {
14			// TODO: Test user interactions
15		});
16	});
17
18	describe('Edge Cases', () => {
19		it.skip('should handle empty data gracefully', async () => {
20			// TODO: Test edge cases
21		});
22	});
23});

The Mock Verification Pattern

Always verify your mocks work:

1describe('Mock Verification', () => {
2	it('should have utility functions mocked correctly', async () => {
3		const { my_util_function } =
4			await import('#lib/utils/my-utils.js');
5
6		expect(my_util_function).toBeDefined();
7		expect(vi.isMockFunction(my_util_function)).toBe(true);
8	});
9});

The Accessibility Test Pattern

1it('should be accessible', async () => {
2	const children = createRawSnippet(() => ({
3		render: () => `<span>Submit</span>`,
4	}));
5
6	await render(MyComponent, { children });
7
8	const button = page.getByRole('button', { name: 'Submit' });
9	await expect.element(button).toHaveAttribute('aria-label');
10
11	// Test keyboard navigation
12	await page.keyboard.press('Tab');
13	await expect.element(button).toBeFocused();
14});

Common First-Day Issues

“strict mode violation: getByRole() resolved to X elements”

Most common issue with Vitest Browser Mode. Multiple elements match your locator:

1// ❌ FAILS: Multiple nav links (desktop + mobile)
2page.getByRole('link', { name: 'Home' });
3
4// ✅ WORKS: Target specific element
5page.getByRole('link', { name: 'Home' }).first();

“My test is hanging, what’s wrong?”

Usually caused by clicking form submit buttons with SvelteKit enhance. Test form state directly:

1// ❌ Can hang with SvelteKit forms
2await submit_button.click();
3
4// ✅ Test the state directly
5await render(MyForm, { errors: { email: 'Required' } });
6await expect.element(page.getByText('Required')).toBeInTheDocument();

“Expected 2 arguments, but got 0”

Your mock function signature doesn’t match the real function:

1// ❌ Wrong signature
2vi.mock('#lib/utils.js', () => ({
3	my_function: vi.fn(),
4}));
5
6// ✅ Correct signature
7vi.mock('#lib/utils.js', () => ({
8	my_function: vi.fn((param1: string, param2: number) => 'result'),
9}));

Role and Element Confusion

1// ❌ WRONG: Looking for link when element has role="button"
2page.getByRole('link', { name: 'Submit' }); // <a role="button">Submit</a>
3
4// ✅ CORRECT: Use the actual role
5page.getByRole('button', { name: 'Submit' });
6
7// ❌ WRONG: Input role doesn't exist
8page.getByRole('input', { name: 'Email' });
9
10// ✅ CORRECT: Use textbox for input elements
11page.getByRole('textbox', { name: 'Email' });

Explore the Examples (Optional)

Want to see these patterns in action? Clone the Sveltest repository:

1# Clone to explore examples
2git clone https://github.com/spences10/sveltest.git
3cd sveltest
4pnpm install
5
6# Run the example tests
7pnpm test:unit

What’s Next?

Now that you’ve written your first test with Vitest Browser Mode, explore these areas:

  1. Testing Patterns - Learn component, SSR, and server testing patterns
  2. Best Practices - Master the Foundation First approach and avoid common pitfalls
  3. API Reference - Complete reference for all testing utilities
  4. Migration Guide - If you’re coming from @testing-library/svelte

Ready to Level Up?

You now have the foundation to write effective tests with Vitest Browser Mode and vitest-browser-svelte. The patterns you’ve learned here scale from simple buttons to complex applications.

Next Steps:

Happy testing! 🧪✨