Svelte vs React Testing Patterns: What Changes?

Svelte vs React Testing Patterns: What Changes?

React and Svelte both work with Testing Library, but the details differ enough that patterns learned in React don't always translate directly. This guide covers what changes, what stays the same, and the Svelte-specific patterns you need to know.

Setup

Both ecosystems use Vitest + Testing Library.

React:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// vite.config.ts
test: {
  environment: 'jsdom',
  globals: true,
}

Svelte:

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

test: {
  environment: 'jsdom',
  globals: true,
},
plugins: [svelte({ hot: !process.env.VITEST })]

Rendering Components

React

import { render, screen } from '@testing-library/react';
import Button from './Button';

test('renders label', () => {
  render(<Button label="Click me" />);
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

Svelte

import { render, screen } from '@testing-library/svelte';
import Button from './Button.svelte';

test('renders label', () => {
  render(Button, { props: { label: 'Click me' } });
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

Key difference: React renders JSX directly; Svelte passes props as an object in the second argument to render.

Updating Props

In React you re-render with new props. In Svelte, render returns a rerender and component reference:

React

const { rerender } = render(<Counter count={0} />);
expect(screen.getByText('0')).toBeInTheDocument();

rerender(<Counter count={5} />);
expect(screen.getByText('5')).toBeInTheDocument();

Svelte

const { rerender } = render(Counter, { props: { count: 0 } });
expect(screen.getByText('0')).toBeInTheDocument();

await rerender({ count: 5 });
expect(screen.getByText('5')).toBeInTheDocument();

Key difference: Svelte's rerender is async and takes just the updated props (not the full component + props structure).

Event Handling

The @testing-library/user-event API is the same in both:

import userEvent from '@testing-library/user-event';

test('increments on click', async () => {
  const user = userEvent.setup();
  render(Counter, { props: { initial: 0 } });

  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('1')).toBeInTheDocument();
});

This pattern is identical between React and Svelte.

Svelte event dispatching

Svelte components dispatch custom events via createEventDispatcher. React uses callback props. The test side looks different:

React (callback prop):

test('calls onSelect when item clicked', async () => {
  const onSelect = vi.fn();
  const user = userEvent.setup();
  render(<ItemList onSelect={onSelect} items={['a', 'b']} />);

  await user.click(screen.getByText('a'));
  expect(onSelect).toHaveBeenCalledWith('a');
});

Svelte (custom event):

test('dispatches select event when item clicked', async () => {
  const user = userEvent.setup();
  const { component } = render(ItemList, {
    props: { items: ['a', 'b'] }
  });

  const handler = vi.fn();
  component.$on('select', handler);

  await user.click(screen.getByText('a'));
  expect(handler).toHaveBeenCalledWith(
    expect.objectContaining({ detail: 'a' })
  );
});

Key difference: In Svelte you use component.$on('eventName', handler) and the handler receives a CustomEvent with the payload in .detail. React handlers receive the value directly.

Reactivity and the DOM Flush

Svelte's reactive assignments update the DOM asynchronously (via microtasks). After an action that triggers reactivity, you need to wait for the DOM to update.

React — user-event actions automatically flush updates.

Svelte — same, but when you directly interact with the component's state (not via user events), you need tick():

import { tick } from 'svelte';
import { get } from 'svelte/store';

test('reflects store update in DOM', async () => {
  render(Counter);
  count.set(10); // update a store directly
  await tick();  // wait for Svelte to flush DOM updates
  expect(screen.getByText('10')).toBeInTheDocument();
});

When using user-event (clicks, typing), this is handled automatically and tick() is not needed.

Stores vs Context

React distributes shared state via Context + hooks. Svelte uses stores (writable, readable, derived).

Testing with Svelte stores

Components that import stores directly:

// count.ts
import { writable } from 'svelte/store';
export const count = writable(0);
<!-- Counter.svelte -->
<script>
  import { count } from './count';
</script>
<span>{$count}</span>
<button on:click={() => count.update(n => n + 1)}>+</button>

Reset the store in beforeEach to avoid test pollution:

import { count } from './count';
import { get } from 'svelte/store';

beforeEach(() => count.set(0));

test('increments store on click', async () => {
  const user = userEvent.setup();
  render(Counter);

  await user.click(screen.getByRole('button', { name: '+' }));
  expect(get(count)).toBe(1);
});

Providing context in tests

React provides context with a wrapper component. Svelte uses the context option in render:

React:

render(
  <ThemeContext.Provider value="dark">
    <Button />
  </ThemeContext.Provider>
);

Svelte:

import { render } from '@testing-library/svelte';

render(Button, {
  context: new Map([['theme', 'dark']])
});

The context option in @testing-library/svelte sets the context map that the component's getContext('theme') will receive.

Slots vs Children

React passes child content as children prop. Svelte uses slots. Testing slots requires a wrapper component.

React:

render(<Card><p>Content</p></Card>);
expect(screen.getByText('Content')).toBeInTheDocument();

Svelte — create a wrapper .svelte file:

<!-- CardWrapper.test.svelte -->
<script>
  import Card from './Card.svelte';
</script>
<Card><p>Content</p></Card>
import CardWrapper from './CardWrapper.test.svelte';

test('renders slot content', () => {
  render(CardWrapper);
  expect(screen.getByText('Content')).toBeInTheDocument();
});

This is the main ergonomic gap: Svelte slot testing requires a wrapper file. There's no JSX equivalent.

Two-Way Binding

Svelte's bind:value creates two-way binding. Simulating it in tests uses user-event:

<!-- SearchInput.svelte -->
<script>
  export let value = '';
</script>
<input bind:value type="text" placeholder="Search" />
test('updates bound value on typing', async () => {
  const user = userEvent.setup();
  const { component } = render(SearchInput);

  await user.type(screen.getByPlaceholderText('Search'), 'hello');
  expect(component.value).toBe('hello');
});

React doesn't have two-way binding — you'd pass value + onChange and test the handler instead.

Testing Svelte Transitions and Animations

Svelte's transition: directives run CSS animations. By default, JSDOM doesn't run them. If your test needs to check post-transition state, mock the transition or test the end state directly.

Disable transitions in tests by setting:

// vitest.setup.ts
import { vi } from 'vitest';
vi.mock('svelte/transition', () => ({
  fade: () => ({ duration: 0 }),
  fly: () => ({ duration: 0 }),
  slide: () => ({ duration: 0 }),
}));

Or structure components to accept a disableAnimation prop for testing.

$effect vs useEffect

React's useEffect side effects run synchronously during testing (with act). Svelte's $effect (Svelte 5) runs on mount and after reactive updates.

// Svelte 5 component with $effect
// Effects run after render; await tick() to ensure they've executed
test('effect runs after mount', async () => {
  render(MyComponent);
  await tick();
  expect(document.title).toBe('Loaded');
});

What Stays Exactly the Same

These patterns from React testing transfer directly to Svelte:

  • screen.getByRole, getByText, getByLabelText, getByPlaceholderText
  • userEvent.click, userEvent.type, userEvent.selectOptions
  • waitFor and findBy* for async assertions
  • within for scoped queries
  • vi.fn() for mocking callbacks
  • Accessibility-first querying strategy

Side-by-Side Reference

Scenario React Svelte
Render component render(<Comp prop="x" />) render(Comp, { props: { prop: 'x' } })
Update props rerender(<Comp prop="y" />) await rerender({ prop: 'y' })
Custom event callback prop + vi.fn() component.$on('event', handler)
Provide context JSX wrapper with Provider render(Comp, { context: new Map() })
Render slot content children in JSX Wrapper .svelte file
Two-way binding N/A component.value after user.type
Flush reactivity automatic via act await tick() for direct state changes
Store reset between tests N/A beforeEach(() => store.set(initial))

Testing End-to-End

Unit and component tests with Testing Library validate individual components in isolation. For testing complete user flows — login, navigation, form submission, real-time updates — HelpMeTest runs end-to-end tests with Playwright in a real browser, written in plain English without source code access. The two layers complement each other regardless of whether you're using Svelte or React.

Read more

Start now free