3 min read
On this page

Unit Testing with Vitest

Vitest is the default test runner for SvelteKit projects. Combined with @testing-library/svelte, it lets you render Svelte components in a simulated DOM, interact with them, and assert on the results. The testing pattern follows arrange-act-assert: render the component, interact with it, then check that the DOM reflects the expected state.

Setting Up Vitest

SvelteKit projects created with npx sv create include Vitest configuration. If you need to set it up manually:

npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    include: ['src/**/*.{test,spec}.{js,ts}'],
    environment: 'jsdom',
    setupFiles: ['./src/tests/setup.ts']
  }
});
// src/tests/setup.ts
import '@testing-library/jest-dom/vitest';

This setup gives you DOM assertions like toBeInTheDocument(), toHaveTextContent(), and toBeVisible().

Rendering Components

Use render from @testing-library/svelte to mount a component in the test DOM:

<!-- src/lib/components/Greeting.svelte -->
<script lang="ts">
  let { name }: { name: string } = $props();
</script>

<h1>Hello, {name}</h1>
// src/lib/components/Greeting.test.ts
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Greeting from './Greeting.svelte';

test('renders greeting with name', () => {
  // Arrange
  render(Greeting, { props: { name: 'Alice' } });

  // Assert
  expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice');
});

The screen object provides queries to find elements by role, text, label, placeholder, and other accessible attributes. Prefer getByRole and getByLabelText over getByTestId because they reflect how users actually find elements.

Testing User Interactions

Use fireEvent or the @testing-library/user-event library to simulate interactions:

<!-- src/lib/components/Counter.svelte -->
<script lang="ts">
  let count = $state(0);
</script>

<p>Count: {count}</p>
<button onclick={() => count++}>Increment</button>
<button onclick={() => count--}>Decrement</button>
// src/lib/components/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';

test('increments count on button click', async () => {
  // Arrange
  render(Counter);

  // Act
  const incrementButton = screen.getByText('Increment');
  await fireEvent.click(incrementButton);
  await fireEvent.click(incrementButton);

  // Assert
  expect(screen.getByText('Count: 2')).toBeInTheDocument();
});

test('decrements count on button click', async () => {
  render(Counter);

  await fireEvent.click(screen.getByText('Decrement'));

  expect(screen.getByText('Count: -1')).toBeInTheDocument();
});

fireEvent is async because Svelte needs to update the DOM after state changes. Always await the event.

Testing Reactivity

When state changes, the DOM should update. Test this by interacting with the component and checking the result:

<!-- src/lib/components/TodoList.svelte -->
<script lang="ts">
  let items = $state<string[]>([]);
  let newItem = $state('');

  function addItem() {
    if (newItem.trim()) {
      items.push(newItem.trim());
      newItem = '';
    }
  }
</script>

<form onsubmit={(e) => { e.preventDefault(); addItem(); }}>
  <input
    type="text"
    bind:value={newItem}
    placeholder="Add a todo"
    aria-label="New todo"
  />
  <button type="submit">Add</button>
</form>

<ul>
  {#each items as item}
    <li>{item}</li>
  {/each}
</ul>
// src/lib/components/TodoList.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import TodoList from './TodoList.svelte';

test('adds items to the list', async () => {
  render(TodoList);

  const input = screen.getByLabelText('New todo');
  const addButton = screen.getByText('Add');

  // Add first item
  await fireEvent.input(input, { target: { value: 'Buy groceries' } });
  await fireEvent.click(addButton);

  // Add second item
  await fireEvent.input(input, { target: { value: 'Walk the dog' } });
  await fireEvent.click(addButton);

  // Assert both items appear
  expect(screen.getByText('Buy groceries')).toBeInTheDocument();
  expect(screen.getByText('Walk the dog')).toBeInTheDocument();

  // Assert the list has two items
  const listItems = screen.getAllByRole('listitem');
  expect(listItems).toHaveLength(2);
});

test('clears input after adding', async () => {
  render(TodoList);

  const input = screen.getByLabelText('New todo') as HTMLInputElement;

  await fireEvent.input(input, { target: { value: 'Test item' } });
  await fireEvent.click(screen.getByText('Add'));

  expect(input.value).toBe('');
});

test('does not add empty items', async () => {
  render(TodoList);

  await fireEvent.input(screen.getByLabelText('New todo'), {
    target: { value: '   ' }
  });
  await fireEvent.click(screen.getByText('Add'));

  expect(screen.queryAllByRole('listitem')).toHaveLength(0);
});

Testing Conditional Rendering

Components that show or hide content based on state:

<!-- src/lib/components/Alert.svelte -->
<script lang="ts">
  let { type = 'info', message, dismissible = false }:
    { type?: 'info' | 'warning' | 'error'; message: string; dismissible?: boolean } = $props();

  let visible = $state(true);
</script>

{#if visible}
  <div class="alert alert-{type}" role="alert">
    <p>{message}</p>
    {#if dismissible}
      <button onclick={() => visible = false} aria-label="Dismiss">X</button>
    {/if}
  </div>
{/if}
// src/lib/components/Alert.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Alert from './Alert.svelte';

test('displays the alert message', () => {
  render(Alert, { props: { message: 'Something happened' } });
  expect(screen.getByRole('alert')).toHaveTextContent('Something happened');
});

test('dismissible alert can be closed', async () => {
  render(Alert, {
    props: { message: 'Close me', dismissible: true }
  });

  expect(screen.getByRole('alert')).toBeInTheDocument();

  await fireEvent.click(screen.getByLabelText('Dismiss'));

  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

test('non-dismissible alert has no close button', () => {
  render(Alert, {
    props: { message: 'Stay here', dismissible: false }
  });

  expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
});

Mocking Fetch & Modules

Use Vitest's mocking capabilities to isolate components from external dependencies:

// src/lib/components/UserProfile.test.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import UserProfile from './UserProfile.svelte';

// Mock the fetch function
global.fetch = vi.fn();

test('displays user data after fetching', async () => {
  const mockUser = { name: 'Alice', email: 'alice@example.com' };

  (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
    new Response(JSON.stringify(mockUser), {
      headers: { 'Content-Type': 'application/json' }
    })
  );

  render(UserProfile, { props: { userId: '123' } });

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });
});

Mocking modules:

// src/lib/components/Dashboard.test.ts
import { render, screen } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';

// Mock an imported module
vi.mock('$lib/api', () => ({
  getStats: vi.fn().mockResolvedValue({
    users: 100,
    revenue: 50000
  })
}));

import Dashboard from './Dashboard.svelte';

test('displays stats from API', async () => {
  render(Dashboard);

  await screen.findByText('100');
  expect(screen.getByText('$50,000')).toBeInTheDocument();
});

Testing Utility Functions

Not everything needs component rendering. Pure functions should be tested directly:

// src/lib/utils/format.ts
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}
// src/lib/utils/format.test.ts
import { expect, test, describe } from 'vitest';
import { formatCurrency, slugify } from './format';

describe('formatCurrency', () => {
  test('formats whole numbers', () => {
    expect(formatCurrency(1000)).toBe('$1,000.00');
  });

  test('formats decimals', () => {
    expect(formatCurrency(29.99)).toBe('$29.99');
  });

  test('handles zero', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });
});

describe('slugify', () => {
  test('converts spaces to hyphens', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  test('removes special characters', () => {
    expect(slugify('What is this?!')).toBe('what-is-this');
  });

  test('trims leading and trailing hyphens', () => {
    expect(slugify('  hello  ')).toBe('hello');
  });
});

Running Tests

# Run all tests
npx vitest

# Run tests once (CI mode)
npx vitest run

# Run specific test file
npx vitest src/lib/components/Counter.test.ts

# Run tests matching a pattern
npx vitest --reporter=verbose Counter

# Watch mode (default)
npx vitest --watch

Common Pitfalls

  • Not awaiting fireEvent calls. Svelte updates the DOM asynchronously after state changes. Without await, your assertions run before the DOM updates and see stale content.
  • Using getBy when the element might not exist. getByText throws if the element is not found. Use queryByText when you want to assert absence, as it returns null instead of throwing.
  • Testing implementation details. Do not assert on CSS classes, internal state variables, or component internals. Test what the user sees: text content, visibility, and behavior after interaction.
  • Forgetting to clean up mocks. Mocks persist across tests in the same file. Use vi.restoreAllMocks() in afterEach or vi.clearAllMocks() to prevent test pollution.
  • Over-relying on snapshot tests. Snapshots are brittle. A minor CSS change or text update breaks them. Use explicit assertions on specific content instead.

Key Takeaways

  • Use render to mount components, screen to query the DOM, and fireEvent to simulate interactions.
  • Follow arrange-act-assert: render the component, interact with it, then check the result.
  • Always await fireEvent calls so Svelte has time to update the DOM.
  • Use getByRole and getByLabelText over getByTestId for queries that reflect real user behavior.
  • Mock external dependencies with vi.fn() and vi.mock() to isolate components.
  • Test utility functions directly without component rendering. Not everything needs the DOM.
  • Run npx vitest run in CI for a single pass, npx vitest locally for watch mode.