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.