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,getByPlaceholderTextuserEvent.click,userEvent.type,userEvent.selectOptionswaitForandfindBy*for async assertionswithinfor scoped queriesvi.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.