Svelte 5 Runes Testing Patterns

Overview

This guide covers testing patterns specific to Svelte 5’s runes system ($state, $derived, $effect). For general testing patterns, see Testing Patterns.

Key Rule: Always use untrack() when accessing $derived values in tests.

$state and $derived Testing

1describe('Reactive State Component', () => {
2	it('should handle $state updates', async () => {
3		await render(CounterComponent);
4
5		const count_display = page.getByTestId('count');
6		const increment_button = page.getByRole('button', {
7			name: 'Increment',
8		});
9
10		// Initial state
11		await expect.element(count_display).toHaveTextContent('0');
12
13		// Update state
14		await increment_button.click();
15		await expect.element(count_display).toHaveTextContent('1');
16	});
17
18	it('should handle $derived values with untrack', () => {
19		let count = $state(0);
20		let doubled = $derived(count * 2);
21
22		// ✅ Always use untrack() when accessing $derived values
23		expect(untrack(() => doubled)).toBe(0);
24
25		count = 5;
26		flushSync(); // Ensure derived state is evaluated
27
28		expect(untrack(() => doubled)).toBe(10);
29	});
30
31	it('should handle $derived from object getters', () => {
32		const state_object = {
33			get computed_value() {
34				return $derived(() => some_calculation());
35			},
36		};
37
38		// ✅ Get the $derived function first, then use untrack
39		const derived_fn = state_object.computed_value;
40		expect(untrack(() => derived_fn())).toBe(expected_value);
41	});
42});

Untrack Patterns

Why untrack() is Required

Svelte 5’s $derived values track their dependencies. In tests, accessing them without untrack() can cause unexpected reactivity issues. Always wrap access in untrack().

Basic Pattern

1import { flushSync, untrack } from 'svelte';
2
3// ✅ CORRECT
4expect(untrack(() => derived_value)).toBe(expected);
5
6// ❌ WRONG - may cause reactivity issues
7expect(derived_value).toBe(expected);

Real-World Examples

Testing Form State with Multiple $derived Values

1// From form-state.test.ts - Testing complex derived state
2describe('Form State Derived Values', () => {
3	it('should validate form state correctly', () => {
4		const form = create_form_state({
5			email: { value: '', validation_rules: { required: true } },
6			password: {
7				value: '',
8				validation_rules: { required: true, min_length: 8 },
9			},
10		});
11
12		// Test initial state
13		expect(untrack(() => form.is_form_valid())).toBe(true);
14		expect(untrack(() => form.has_changes())).toBe(false);
15		expect(untrack(() => form.field_errors())).toEqual({});
16
17		// Update field and test derived state changes
18		form.update_field('email', 'invalid');
19		flushSync();
20
21		expect(untrack(() => form.is_form_valid())).toBe(false);
22		expect(untrack(() => form.has_changes())).toBe(true);
23
24		const errors = untrack(() => form.field_errors());
25		expect(errors.email).toBe('Invalid format');
26	});
27});

Testing Calculator State Transitions

1// From calculator.test.ts - Testing state getters
2describe('Calculator State Management', () => {
3	it('should handle calculator state transitions', () => {
4		// Test initial state
5		expect(untrack(() => calculator_state.current_value)).toBe('0');
6		expect(untrack(() => calculator_state.previous_value)).toBe('');
7		expect(untrack(() => calculator_state.operation)).toBe('');
8		expect(untrack(() => calculator_state.waiting_for_operand)).toBe(
9			false,
10		);
11
12		// Perform operation and test state changes
13		calculator_state.input_digit('5');
14		calculator_state.input_operation('+');
15		flushSync();
16
17		expect(untrack(() => calculator_state.current_value)).toBe('5');
18		expect(untrack(() => calculator_state.operation)).toBe('+');
19		expect(untrack(() => calculator_state.waiting_for_operand)).toBe(
20			true,
21		);
22	});
23});

flushSync Usage

Use flushSync() to ensure reactive state updates are processed before assertions:

1import { flushSync } from 'svelte';
2
3it('should update derived state after flushSync', () => {
4	let count = $state(0);
5	let doubled = $derived(count * 2);
6
7	count = 5;
8	// Without flushSync, derived might not be updated yet
9	flushSync();
10
11	expect(untrack(() => doubled)).toBe(10);
12});

When to Use flushSync

  • After updating $state values
  • Before asserting on $derived values
  • When testing state transitions

Creating $derived State in Tests

Key Discovery: Runes can only be used in .test.svelte.ts files, not regular .ts files!

1// From untrack-validation.test.svelte.ts - PROVEN WORKING PATTERN
2describe('Untrack Usage Validation', () => {
3	it('should access $derived values using untrack', () => {
4		// ✅ Create reactive state directly in test (.test.svelte.ts file)
5		let email = $state('');
6		const email_validation = $derived(validate_email(email));
7
8		// Test invalid email
9		email = 'invalid-email';
10		flushSync();
11
12		// ✅ CORRECT: Use untrack to access $derived value
13		const result = untrack(() => email_validation);
14		expect(result.is_valid).toBe(false);
15		expect(result.error_message).toBe('Invalid format');
16
17		// Test valid email
18		email = '[email protected]';
19		flushSync();
20
21		const valid_result = untrack(() => email_validation);
22		expect(valid_result.is_valid).toBe(true);
23		expect(valid_result.error_message).toBe('');
24	});
25
26	it('should handle complex derived logic', () => {
27		// ✅ Recreate component logic in test
28		let email = $state('');
29		let submit_attempted = $state(false);
30		let email_touched = $state(false);
31
32		const email_validation = $derived(validate_email(email));
33		const show_email_error = $derived(
34			submit_attempted || email_touched,
35		);
36		const email_error = $derived(
37			show_email_error && !email_validation.is_valid
38				? email_validation.error_message
39				: '',
40		);
41
42		// Initially no errors shown
43		expect(untrack(() => show_email_error)).toBe(false);
44		expect(untrack(() => email_error)).toBe('');
45
46		// After touching field with invalid email
47		email = 'invalid';
48		email_touched = true;
49		flushSync();
50
51		expect(untrack(() => show_email_error)).toBe(true);
52		expect(untrack(() => email_error)).toBe('Invalid format');
53	});
54
55	it('should test state transitions with untrack', () => {
56		// ✅ Test reactive state changes
57		let count = $state(0);
58		let doubled = $derived(count * 2);
59		let is_even = $derived(count % 2 === 0);
60
61		// Initial state
62		expect(untrack(() => count)).toBe(0);
63		expect(untrack(() => doubled)).toBe(0);
64		expect(untrack(() => is_even)).toBe(true);
65
66		// Update state
67		count = 3;
68		flushSync();
69
70		// Test all derived values
71		expect(untrack(() => count)).toBe(3);
72		expect(untrack(() => doubled)).toBe(6);
73		expect(untrack(() => is_even)).toBe(false);
74	});
75
76	it('should handle form validation patterns', () => {
77		// ✅ Recreate login form validation logic
78		let email = $state('');
79		let password = $state('');
80		let loading = $state(false);
81
82		const email_validation = $derived(validate_email(email));
83		const password_validation = $derived(validate_password(password));
84		const form_is_valid = $derived(
85			email_validation.is_valid && password_validation.is_valid,
86		);
87		const can_submit = $derived(form_is_valid && !loading);
88
89		// Test form validation chain
90		email = '[email protected]';
91		password = 'ValidPassword123';
92		flushSync();
93
94		expect(untrack(() => email_validation.is_valid)).toBe(true);
95		expect(untrack(() => password_validation.is_valid)).toBe(true);
96		expect(untrack(() => form_is_valid)).toBe(true);
97		expect(untrack(() => can_submit)).toBe(true);
98
99		// Test loading state
100		loading = true;
101		flushSync();
102
103		expect(untrack(() => can_submit)).toBe(false);
104	});
105});

Testing Component $derived Values

Component internals are encapsulated in Svelte 5. Test through UI:

1describe('LoginForm Derived State', () => {
2	it('should validate email and show errors through UI', async () => {
3		await render(LoginForm);
4
5		// ✅ Test through UI interactions
6		const email_input = page.getByLabelText('Email');
7		await email_input.fill('invalid-email');
8		await email_input.element().blur();
9
10		await expect
11			.element(page.getByText('Invalid format'))
12			.toBeInTheDocument();
13	});
14});

Form Validation Lifecycle Pattern

Forms typically start valid (not yet validated), become invalid on validation, then valid again when fixed:

1describe('Form Validation Component', () => {
2	it('should follow validation lifecycle', () => {
3		const form_state = create_form_state({
4			email: {
5				value: '',
6				validation_rules: { required: true },
7			},
8		});
9
10		// ✅ CORRECT: Forms typically start valid (not validated yet)
11		const is_form_valid = form_state.is_form_valid;
12		expect(untrack(() => is_form_valid())).toBe(true);
13
14		// Trigger validation - now should be invalid
15		form_state.validate_all_fields();
16		flushSync();
17		expect(untrack(() => is_form_valid())).toBe(false);
18
19		// Fix the field - should become valid again
20		form_state.update_field('email', '[email protected]');
21		flushSync();
22		expect(untrack(() => is_form_valid())).toBe(true);
23	});
24
25	it('should handle field-level validation', async () => {
26		await render(FormComponent);
27
28		const email_input = page.getByLabelText('Email');
29
30		// Initially no error
31		await expect
32			.element(page.getByText('Email is required'))
33			.not.toBeInTheDocument();
34
35		// Trigger validation by focusing and blurring
36		await email_input.element().focus();
37		await email_input.element().blur();
38
39		// Error should appear
40		await expect
41			.element(page.getByText('Email is required'))
42			.toBeInTheDocument();
43
44		// Fix the error
45		await email_input.fill('[email protected]');
46		await email_input.element().blur();
47
48		// Error should disappear
49		await expect
50			.element(page.getByText('Email is required'))
51			.not.toBeInTheDocument();
52	});
53});

Testing $props

Component props declared with $props() are tested by passing them through render(). You don’t access $props directly in tests — verify the component renders correctly with the given props:

1describe('Button Props', () => {
2	it('should render with variant prop', async () => {
3		await render(Button, {
4			props: {
5				variant: 'primary',
6				disabled: true,
7				children: createRawSnippet(() => ({
8					render: () => 'Submit',
9				})),
10			},
11		});
12
13		const button = page.getByRole('button', { name: 'Submit' });
14		await expect.element(button).toHaveClass('btn-primary');
15		await expect.element(button).toBeDisabled();
16	});
17
18	it('should handle default prop values', async () => {
19		await render(Button); // No props — uses defaults
20
21		const button = page.getByRole('button');
22		await expect.element(button).not.toBeDisabled();
23	});
24});

For $bindable props, test two-way binding through UI interactions rather than asserting on prop values directly.

Testing $effect

Effects are side effects that run when their dependencies change. You cannot observe $effect directly in tests — instead, test the observable outcomes:

1describe('Auto-save Effect', () => {
2	it('should persist state changes to localStorage', async () => {
3		await render(SettingsForm);
4
5		const theme_select = page.getByRole('combobox', {
6			name: 'Theme',
7		});
8		await theme_select.selectOption('dark');
9
10		// Test the effect's observable outcome
11		expect(localStorage.getItem('theme')).toBe('dark');
12	});
13});

If the effect updates DOM or triggers visible changes, assert on those. If it calls an external API, mock the API and verify the call. The pattern is always: trigger the dependency change, then assert on the side effect’s result.

Testing $effect in Unit Tests with $effect.root

When testing effects outside of components (in .svelte.test.ts files), wrap them in $effect.root() to manage cleanup:

1it('should run effect when dependency changes', () => {
2	let log: string[] = [];
3
4	const cleanup = $effect.root(() => {
5		let count = $state(0);
6
7		$effect(() => {
8			log.push(`count: ${count}`);
9		});
10
11		flushSync();
12		expect(log).toEqual(['count: 0']);
13
14		count = 5;
15		flushSync();
16		expect(log).toEqual(['count: 0', 'count: 5']);
17	});
18
19	cleanup(); // Dispose all effects
20});

Without $effect.root(), effects created in tests will leak and may cause unexpected behavior in subsequent tests.

Quick Reference

Essential Rules

  • ✅ Use untrack() for $derived: expect(untrack(() => derived_value))
  • ✅ Use flushSync() after state updates before assertions
  • ✅ Use .test.svelte.ts files to create runes in tests
  • ✅ Test component derived values through UI, not internals
  • ✅ Forms start valid (not validated) → validate → fix

Common Pattern

1import { flushSync, untrack } from 'svelte';
2
3let state = $state(initial);
4let derived = $derived(compute(state));
5
6state = new_value;
7flushSync();
8expect(untrack(() => derived)).toBe(expected);