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
describe('Reactive State Component', () => {
it('should handle $state updates', async () => {
render(CounterComponent);
const count_display = page.getByTestId('count');
const increment_button = page.getByRole('button', {
name: 'Increment',
});
// Initial state
await expect.element(count_display).toHaveTextContent('0');
// Update state
await increment_button.click();
await expect.element(count_display).toHaveTextContent('1');
});
it('should handle $derived values with untrack', () => {
let count = $state(0);
let doubled = $derived(count * 2);
// ✅ Always use untrack() when accessing $derived values
expect(untrack(() => doubled)).toBe(0);
count = 5;
flushSync(); // Ensure derived state is evaluated
expect(untrack(() => doubled)).toBe(10);
});
it('should handle $derived from object getters', () => {
const state_object = {
get computed_value() {
return $derived(() => some_calculation());
},
};
// ✅ Get the $derived function first, then use untrack
const derived_fn = state_object.computed_value;
expect(untrack(() => derived_fn())).toBe(expected_value);
});
}); 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
import { flushSync, untrack } from 'svelte';
// ✅ CORRECT
expect(untrack(() => derived_value)).toBe(expected);
// ❌ WRONG - may cause reactivity issues
expect(derived_value).toBe(expected); Real-World Examples
Testing Form State with Multiple $derived Values
// From form-state.test.ts - Testing complex derived state
describe('Form State Derived Values', () => {
it('should validate form state correctly', () => {
const form = create_form_state({
email: { value: '', validation_rules: { required: true } },
password: {
value: '',
validation_rules: { required: true, min_length: 8 },
},
});
// Test initial state
expect(untrack(() => form.is_form_valid())).toBe(true);
expect(untrack(() => form.has_changes())).toBe(false);
expect(untrack(() => form.field_errors())).toEqual({});
// Update field and test derived state changes
form.update_field('email', 'invalid');
flushSync();
expect(untrack(() => form.is_form_valid())).toBe(false);
expect(untrack(() => form.has_changes())).toBe(true);
const errors = untrack(() => form.field_errors());
expect(errors.email).toBe('Invalid format');
});
}); Testing Calculator State Transitions
// From calculator.test.ts - Testing state getters
describe('Calculator State Management', () => {
it('should handle calculator state transitions', () => {
// Test initial state
expect(untrack(() => calculator_state.current_value)).toBe('0');
expect(untrack(() => calculator_state.previous_value)).toBe('');
expect(untrack(() => calculator_state.operation)).toBe('');
expect(untrack(() => calculator_state.waiting_for_operand)).toBe(
false,
);
// Perform operation and test state changes
calculator_state.input_digit('5');
calculator_state.input_operation('+');
flushSync();
expect(untrack(() => calculator_state.current_value)).toBe('5');
expect(untrack(() => calculator_state.operation)).toBe('+');
expect(untrack(() => calculator_state.waiting_for_operand)).toBe(
true,
);
});
}); flushSync Usage
Use flushSync() to ensure reactive state updates are processed
before assertions:
import { flushSync } from 'svelte';
it('should update derived state after flushSync', () => {
let count = $state(0);
let doubled = $derived(count * 2);
count = 5;
// Without flushSync, derived might not be updated yet
flushSync();
expect(untrack(() => doubled)).toBe(10);
}); 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!
// From untrack-validation.test.svelte.ts - PROVEN WORKING PATTERN
describe('Untrack Usage Validation', () => {
it('should access $derived values using untrack', () => {
// ✅ Create reactive state directly in test (.test.svelte.ts file)
let email = $state('');
const email_validation = $derived(validate_email(email));
// Test invalid email
email = 'invalid-email';
flushSync();
// ✅ CORRECT: Use untrack to access $derived value
const result = untrack(() => email_validation);
expect(result.is_valid).toBe(false);
expect(result.error_message).toBe('Invalid format');
// Test valid email
email = '[email protected]';
flushSync();
const valid_result = untrack(() => email_validation);
expect(valid_result.is_valid).toBe(true);
expect(valid_result.error_message).toBe('');
});
it('should handle complex derived logic', () => {
// ✅ Recreate component logic in test
let email = $state('');
let submit_attempted = $state(false);
let email_touched = $state(false);
const email_validation = $derived(validate_email(email));
const show_email_error = $derived(
submit_attempted || email_touched,
);
const email_error = $derived(
show_email_error && !email_validation.is_valid
? email_validation.error_message
: '',
);
// Initially no errors shown
expect(untrack(() => show_email_error)).toBe(false);
expect(untrack(() => email_error)).toBe('');
// After touching field with invalid email
email = 'invalid';
email_touched = true;
flushSync();
expect(untrack(() => show_email_error)).toBe(true);
expect(untrack(() => email_error)).toBe('Invalid format');
});
it('should test state transitions with untrack', () => {
// ✅ Test reactive state changes
let count = $state(0);
let doubled = $derived(count * 2);
let is_even = $derived(count % 2 === 0);
// Initial state
expect(untrack(() => count)).toBe(0);
expect(untrack(() => doubled)).toBe(0);
expect(untrack(() => is_even)).toBe(true);
// Update state
count = 3;
flushSync();
// Test all derived values
expect(untrack(() => count)).toBe(3);
expect(untrack(() => doubled)).toBe(6);
expect(untrack(() => is_even)).toBe(false);
});
it('should handle form validation patterns', () => {
// ✅ Recreate login form validation logic
let email = $state('');
let password = $state('');
let loading = $state(false);
const email_validation = $derived(validate_email(email));
const password_validation = $derived(validate_password(password));
const form_is_valid = $derived(
email_validation.is_valid && password_validation.is_valid,
);
const can_submit = $derived(form_is_valid && !loading);
// Test form validation chain
email = '[email protected]';
password = 'ValidPassword123';
flushSync();
expect(untrack(() => email_validation.is_valid)).toBe(true);
expect(untrack(() => password_validation.is_valid)).toBe(true);
expect(untrack(() => form_is_valid)).toBe(true);
expect(untrack(() => can_submit)).toBe(true);
// Test loading state
loading = true;
flushSync();
expect(untrack(() => can_submit)).toBe(false);
});
}); Testing Component $derived Values
Component internals are encapsulated in Svelte 5. Test through UI:
describe('LoginForm Derived State', () => {
it('should validate email and show errors through UI', async () => {
render(LoginForm);
// ✅ Test through UI interactions
const email_input = page.getByLabelText('Email');
await email_input.fill('invalid-email');
await email_input.element().blur();
await expect
.element(page.getByText('Invalid format'))
.toBeInTheDocument();
});
}); Form Validation Lifecycle Pattern
Forms typically start valid (not yet validated), become invalid on validation, then valid again when fixed:
describe('Form Validation Component', () => {
it('should follow validation lifecycle', () => {
const form_state = create_form_state({
email: {
value: '',
validation_rules: { required: true },
},
});
// ✅ CORRECT: Forms typically start valid (not validated yet)
const is_form_valid = form_state.is_form_valid;
expect(untrack(() => is_form_valid())).toBe(true);
// Trigger validation - now should be invalid
form_state.validate_all_fields();
flushSync();
expect(untrack(() => is_form_valid())).toBe(false);
// Fix the field - should become valid again
form_state.update_field('email', '[email protected]');
flushSync();
expect(untrack(() => is_form_valid())).toBe(true);
});
it('should handle field-level validation', async () => {
render(FormComponent);
const email_input = page.getByLabelText('Email');
// Initially no error
await expect
.element(page.getByText('Email is required'))
.not.toBeInTheDocument();
// Trigger validation by focusing and blurring
await email_input.element().focus();
await email_input.element().blur();
// Error should appear
await expect
.element(page.getByText('Email is required'))
.toBeInTheDocument();
// Fix the error
await email_input.fill('[email protected]');
await email_input.element().blur();
// Error should disappear
await expect
.element(page.getByText('Email is required'))
.not.toBeInTheDocument();
});
}); 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
import { flushSync, untrack } from 'svelte';
let state = $state(initial);
let derived = $derived(compute(state));
state = new_value;
flushSync();
expect(untrack(() => derived)).toBe(expected);