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
$statevalues - Before asserting on
$derivedvalues - 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.tsfiles 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);