Three Levels of Confidence

Calm systems are testable systems. When data flows through load functions and components have explicit props, testing becomes straightforward.

Three levels of testing serve different purposes:

  1. Unit tests — Test load functions and utilities in isolation
  2. Component tests — Test components render correctly
  3. E2E tests — Test complete user flows in a real browser

Unit Testing Load Functions

Load functions are just async functions. Test them like any other function.

// routes/projects/[id]/+page.server.ts
import { error } from '@sveltejs/kit'
import { db } from '$lib/server/db'

export async function load({ params, locals }) {
	if (!locals.user) {
		error(401, 'Unauthorized')
	}

	const project = await db.getProject(params.id)

	if (!project) {
		error(404, 'Project not found')
	}

	if (project.ownerId !== locals.user.id) {
		error(403, 'Access denied')
	}

	return { project }
}
// routes/projects/[id]/+page.server.test.ts
import { describe, it, expect, vi } from 'vitest'
import { load } from './+page.server'

// Mock the database
vi.mock('$lib/server/db', () => ({
	db: {
		getProject: vi.fn()
	}
}))

import { db } from '$lib/server/db'

describe('project load function', () => {
	const mockUser = { id: 'user-1', name: 'Alice' }
	const mockProject = { id: 'proj-1', name: 'Test', ownerId: 'user-1' }

	it('returns project for authorized user', async () => {
		vi.mocked(db.getProject).mockResolvedValue(mockProject)

		const result = await load({
			params: { id: 'proj-1' },
			locals: { user: mockUser }
		} as any)

		expect(result.project).toEqual(mockProject)
	})

	it('throws 401 when not authenticated', async () => {
		await expect(
			load({
				params: { id: 'proj-1' },
				locals: { user: null }
			} as any)
		).rejects.toMatchObject({ status: 401 })
	})

	it('throws 404 when project not found', async () => {
		vi.mocked(db.getProject).mockResolvedValue(null)

		await expect(
			load({
				params: { id: 'nonexistent' },
				locals: { user: mockUser }
			} as any)
		).rejects.toMatchObject({ status: 404 })
	})

	it('throws 403 when user lacks access', async () => {
		vi.mocked(db.getProject).mockResolvedValue({
			...mockProject,
			ownerId: 'other-user'
		})

		await expect(
			load({
				params: { id: 'proj-1' },
				locals: { user: mockUser }
			} as any)
		).rejects.toMatchObject({ status: 403 })
	})
})

Load functions are pure—same inputs, same outputs. This makes them easy to test without rendering anything.

Component Testing

Use @testing-library/svelte to test components:

npm install -D @testing-library/svelte @testing-library/jest-dom
<!-- lib/components/TaskCard.svelte -->
<script>
	let { task, oncomplete, ondelete } = $props()
</script>

<div class="task" class:completed={task.completed}>
	<button
		class="toggle"
		onclick={() => oncomplete?.(task.id)}
		aria-label={task.completed ? 'Mark incomplete' : 'Mark complete'}
	>
		{task.completed ? '' : ''}
	</button>
	<span class="title">{task.title}</span>
	<button class="delete" onclick={() => ondelete?.(task.id)} aria-label="Delete task"> × </button>
</div>
// lib/components/TaskCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/svelte'
import userEvent from '@testing-library/user-event'
import TaskCard from './TaskCard.svelte'

describe('TaskCard', () => {
	const mockTask = {
		id: '1',
		title: 'Test task',
		completed: false
	}

	it('renders task title', () => {
		render(TaskCard, { props: { task: mockTask } })

		expect(screen.getByText('Test task')).toBeInTheDocument()
	})

	it('shows incomplete state', () => {
		render(TaskCard, { props: { task: mockTask } })

		expect(screen.getByLabelText('Mark complete')).toBeInTheDocument()
	})

	it('shows completed state', () => {
		render(TaskCard, {
			props: { task: { ...mockTask, completed: true } }
		})

		expect(screen.getByLabelText('Mark incomplete')).toBeInTheDocument()
	})

	it('calls oncomplete when toggle clicked', async () => {
		const user = userEvent.setup()
		const oncomplete = vi.fn()

		render(TaskCard, {
			props: { task: mockTask, oncomplete }
		})

		await user.click(screen.getByLabelText('Mark complete'))

		expect(oncomplete).toHaveBeenCalledWith('1')
	})

	it('calls ondelete when delete clicked', async () => {
		const user = userEvent.setup()
		const ondelete = vi.fn()

		render(TaskCard, {
			props: { task: mockTask, ondelete }
		})

		await user.click(screen.getByLabelText('Delete task'))

		expect(ondelete).toHaveBeenCalledWith('1')
	})
})

Test what users see and do, not implementation details.

E2E Testing with Playwright

For complete user flows, use Playwright:

npm install -D @playwright/test
npx playwright install
// tests/tasks.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Tasks', () => {
	test.beforeEach(async ({ page }) => {
		// Login before each test
		await page.goto('/login')
		await page.fill('input[name="email"]', 'test@example.com')
		await page.fill('input[name="password"]', 'password')
		await page.click('button[type="submit"]')
		await page.waitForURL('/dashboard')
	})

	test('can create a task', async ({ page }) => {
		await page.goto('/tasks')

		// Open form
		await page.click('text=New Task')

		// Fill and submit
		await page.fill('input[name="title"]', 'Buy groceries')
		await page.fill('textarea[name="description"]', 'Milk, eggs, bread')
		await page.click('button[type="submit"]')

		// Verify task appears
		await expect(page.locator('text=Buy groceries')).toBeVisible()
	})

	test('can complete a task', async ({ page }) => {
		await page.goto('/tasks')

		// Find and complete task
		const task = page.locator('.task', { hasText: 'Buy groceries' })
		await task.locator('.toggle').click()

		// Verify completed state
		await expect(task).toHaveClass(/completed/)
	})

	test('can filter tasks', async ({ page }) => {
		await page.goto('/tasks')

		// Filter to pending only
		await page.click('text=Pending')

		// Completed task should be hidden
		await expect(page.locator('.task.completed')).toHaveCount(0)
	})
})

E2E tests are slower but test the full stack. Use them for critical paths.

What to Test

Always test:

  • Load function authorization and error handling
  • Form validation logic
  • Critical user flows (signup, checkout, etc.)

Usually test:

  • Complex derived state logic
  • Component interaction behavior
  • Edge cases in business logic

Rarely test:

  • Simple prop passing
  • CSS styling
  • Third-party library behavior

Test Structure

src/
├── lib/
│   ├── components/
│   │   ├── TaskCard.svelte
│   │   └── TaskCard.test.ts     # Component tests
│   └── server/
│       ├── db.ts
│       └── db.test.ts           # Utility tests
├── routes/
│   └── tasks/
│       ├── +page.server.ts
│       └── +page.server.test.ts # Load function tests
└── tests/
    └── tasks.spec.ts            # E2E tests

Co-locate unit and component tests. Keep E2E tests in a separate folder.

Running Tests

# Unit and component tests
npm run test

# E2E tests
npm run test:e2e

# Watch mode for development
npm run test -- --watch

Tests are documentation. They show how code is meant to be used. When tests are easy to write, it usually means the code is well-designed.