Svelte Component Integration Testing with @testing-library/svelte

Svelte Component Integration Testing with @testing-library/svelte

Integration tests in Svelte sit between unit tests (single components in isolation) and E2E tests (full browser). They render a component tree with real child components, real stores, and mocked external dependencies — verifying that the pieces work together without the overhead of a browser.

Setup

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

export default defineConfig({
  plugins: [svelte({ hot: !process.env.VITEST })],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

A Real Integration Test: Checkout Flow

Rather than testing individual components, integration tests verify that multiple components cooperate correctly. Here's a checkout form with a cart summary and order confirmation:

The Components

<!-- CheckoutForm.svelte -->
<script lang="ts">
  import CartSummary from './CartSummary.svelte';
  import { cartItems, cartTotal } from './stores/cart';
  import { submitOrder } from './api/orders';
  import { notifications } from './stores/notifications';

  let step: 'cart' | 'payment' | 'confirmation' = 'cart';
  let submitting = false;

  async function handleCheckout() {
    submitting = true;
    try {
      await submitOrder({ items: $cartItems, total: $cartTotal });
      step = 'confirmation';
    } catch {
      notifications.add('Order failed. Please try again.', 'error');
    } finally {
      submitting = false;
    }
  }
</script>

{#if step === 'cart'}
  <CartSummary />
  <button on:click={() => (step = 'payment')}>Proceed to Payment</button>
{:else if step === 'payment'}
  <h2>Payment</h2>
  <button disabled={submitting} on:click={handleCheckout}>
    {submitting ? 'Processing...' : 'Place Order'}
  </button>
{:else}
  <h2 data-testid="confirmation">Order confirmed!</h2>
{/if}

The Integration Test

// CheckoutForm.test.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import CheckoutForm from './CheckoutForm.svelte';
import { cartItems } from './stores/cart';
import { notifications } from './stores/notifications';
import * as ordersApi from './api/orders';

vi.mock('./api/orders');

describe('checkout flow', () => {
  beforeEach(() => {
    cartItems.set([
      { id: '1', name: 'Widget', price: 10, quantity: 2 },
      { id: '2', name: 'Gadget', price: 25, quantity: 1 },
    ]);
    notifications.clear();
    vi.clearAllMocks();
  });

  it('renders cart summary with items', () => {
    render(CheckoutForm);

    expect(screen.getByText('Widget')).toBeInTheDocument();
    expect(screen.getByText('Gadget')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Proceed to Payment' })).toBeInTheDocument();
  });

  it('advances to payment step', async () => {
    const user = userEvent.setup();
    render(CheckoutForm);

    await user.click(screen.getByRole('button', { name: 'Proceed to Payment' }));

    expect(screen.getByRole('heading', { name: 'Payment' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Place Order' })).toBeInTheDocument();
  });

  it('shows processing state during order submission', async () => {
    vi.mocked(ordersApi.submitOrder).mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 100))
    );

    const user = userEvent.setup();
    render(CheckoutForm);

    await user.click(screen.getByRole('button', { name: 'Proceed to Payment' }));
    await user.click(screen.getByRole('button', { name: 'Place Order' }));

    expect(screen.getByRole('button', { name: 'Processing...' })).toBeDisabled();
  });

  it('shows confirmation after successful order', async () => {
    vi.mocked(ordersApi.submitOrder).mockResolvedValue({ orderId: 'order-1' });

    const user = userEvent.setup();
    render(CheckoutForm);

    await user.click(screen.getByRole('button', { name: 'Proceed to Payment' }));
    await user.click(screen.getByRole('button', { name: 'Place Order' }));

    await waitFor(() => {
      expect(screen.getByTestId('confirmation')).toBeInTheDocument();
    });
  });

  it('shows error notification on failed order', async () => {
    vi.mocked(ordersApi.submitOrder).mockRejectedValue(new Error('Network error'));

    const user = userEvent.setup();
    render(CheckoutForm);

    await user.click(screen.getByRole('button', { name: 'Proceed to Payment' }));
    await user.click(screen.getByRole('button', { name: 'Place Order' }));

    await waitFor(() => {
      expect(screen.getByText('Order failed. Please try again.')).toBeInTheDocument();
    });
    // Stays on payment step
    expect(screen.getByRole('button', { name: 'Place Order' })).toBeInTheDocument();
  });
});

Testing Store-Connected Components

When a component reads from stores, use real stores and manipulate them directly:

<!-- NotificationList.svelte -->
<script lang="ts">
  import { notifications } from './stores/notifications';
</script>

{#each $notifications as notif (notif.id)}
  <div role="alert" class="notification {notif.type}">
    {notif.message}
    <button on:click={() => notifications.dismiss(notif.id)}>×</button>
  </div>
{/each}
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, beforeEach } from 'vitest';
import NotificationList from './NotificationList.svelte';
import { notifications } from './stores/notifications';

describe('NotificationList', () => {
  beforeEach(() => notifications.clear());

  it('renders nothing when empty', () => {
    render(NotificationList);
    expect(screen.queryAllByRole('alert')).toHaveLength(0);
  });

  it('renders notifications from store', () => {
    notifications.add('Upload complete', 'success');
    notifications.add('Connection lost', 'error');

    render(NotificationList);

    const alerts = screen.getAllByRole('alert');
    expect(alerts).toHaveLength(2);
    expect(alerts[0]).toHaveTextContent('Upload complete');
    expect(alerts[1]).toHaveTextContent('Connection lost');
  });

  it('dismisses a notification on click', async () => {
    notifications.add('Test message');
    render(NotificationList);

    await fireEvent.click(screen.getByRole('button', { name: '×' }));

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

  it('adds CSS class for notification type', () => {
    notifications.add('Success!', 'success');
    render(NotificationList);

    expect(screen.getByRole('alert')).toHaveClass('success');
  });
});

Testing with Context

Svelte's context API is used for dependency injection between parent and child components:

<!-- ThemeProvider.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  export let theme: 'light' | 'dark' = 'light';
  setContext('theme', { theme });
</script>
<slot />
<!-- ThemedButton.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  const { theme } = getContext<{ theme: string }>('theme');
</script>
<button class={theme}>Click me</button>
import { render, screen } from '@testing-library/svelte';
import ThemedButtonInContext from './ThemedButtonInContext.test.svelte';

// Create a wrapper .test.svelte file to provide context:
// <ThemeProvider theme="dark">
//   <ThemedButton />
// </ThemeProvider>

describe('ThemedButton', () => {
  it('applies dark theme class when context is dark', () => {
    render(ThemedButtonInContext);
    expect(screen.getByRole('button')).toHaveClass('dark');
  });
});

Accessibility Integration Tests

import { render } from '@testing-library/svelte';
import { axe, toHaveNoViolations } from 'jest-axe';
import { expect } from 'vitest';
import CheckoutForm from './CheckoutForm.svelte';

expect.extend(toHaveNoViolations);

describe('CheckoutForm accessibility', () => {
  it('has no accessibility violations', async () => {
    const { container } = render(CheckoutForm);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Testing with SvelteKit's $app Stores

// Mock SvelteKit-specific stores
vi.mock('$app/stores', () => {
  const { writable, readable } = require('svelte/store');
  return {
    page: readable({
      url: new URL('http://localhost/products'),
      params: { id: '42' },
      route: { id: '/products/[id]' },
      status: 200,
      error: null,
      data: {},
    }),
    navigating: readable(null),
    updated: readable(false),
  };
});

vi.mock('$app/navigation', () => ({
  goto: vi.fn(),
  invalidate: vi.fn(),
  preloadData: vi.fn(),
}));

Component Cleanup

@testing-library/svelte automatically unmounts components after each test via cleanup() — no manual teardown needed. If you're manipulating stores, reset them in beforeEach:

beforeEach(() => {
  // Reset all stores to known state
  cartItems.set([]);
  notifications.clear();
  vi.clearAllMocks();
});

Choosing Between Unit and Integration Tests

Scenario Unit Test Integration Test
Single component, no children Yes No
Store logic (no component) Yes No
Component + store interaction No Yes
Multi-step user flows No Yes
Component with complex children No Yes
Accessibility validation No Yes

Integration tests are most valuable for user-facing flows that span multiple components — login forms, shopping carts, data tables with sorting and filtering. They catch coordination bugs that unit tests miss, without the flakiness of E2E tests.

Read more

Start now free