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 chromiumYou 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| Filename | Runner | Purpose |
|---|---|---|
*.svelte.test.ts | Vitest client | Components in real Chromium |
*.test.ts excluding component tests | Vitest server | Utilities, load functions, actions, endpoints |
*.ssr.test.ts | Vitest Node; optional separate SSR project | Server-rendered HTML |
*.e2e.ts | Playwright | Complete 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:e2eIn 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
- Shared Validation Logic: Use the same validation functions on both client and server
- Real FormData/Request Objects: Server tests use real web APIs, not mocks
- TypeScript Contracts: Shared interfaces catch mismatches at compile time
- 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.svelteYou 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 snippetsThe 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:
Semantic roles (best for accessibility):
1page.getByRole('button', { name: 'Submit' }); 2page.getByRole('textbox', { name: 'Email' });Labels (good for forms):
1page.getByLabel('Email address');Text content (good for unique text):
1page.getByText('Welcome back');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:unitWhat’s Next?
Now that you’ve written your first test with Vitest Browser Mode, explore these areas:
- Testing Patterns - Learn component, SSR, and server testing patterns
- Best Practices - Master the Foundation First approach and avoid common pitfalls
- API Reference - Complete reference for all testing utilities
- 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:
- Explore the component examples to see these patterns in action
- Check out the todo application for a complete testing example
- Review the shared testing skill and its references for advanced patterns
Happy testing! 🧪✨