Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/reatom/reatom/llms.txt

Use this file to discover all available pages before exploring further.

Testing Reatom Code

Reatom’s architecture makes testing straightforward and predictable. Test your state logic in isolation without complex mocking or framework-specific testing utilities.

Core Testing Principles

Reatom enables effective testing through:
  • Isolation - Test atoms and actions independently
  • Determinism - Pure computations produce predictable results
  • Synchronous Testing - Most tests run synchronously
  • No Mocking - Test real implementations
  • Framework Agnostic - Works with any test runner

Quick Start

Basic Atom Test

import { test, expect } from 'vitest'
import { atom } from '@reatom/core'

test('counter atom', () => {
  const counterAtom = atom(0, 'counterAtom')
  
  // Initial state
  expect(counterAtom()).toBe(0)
  
  // Update state
  counterAtom.set(5)
  expect(counterAtom()).toBe(5)
  
  // Update with function
  counterAtom.set((prev) => prev + 1)
  expect(counterAtom()).toBe(6)
})

Action Test

import { test, expect } from 'vitest'
import { atom, action } from '@reatom/core'

test('increment action', () => {
  const counterAtom = atom(0, 'counterAtom')
  
  const increment = action(() => {
    counterAtom.set((prev) => prev + 1)
  }, 'increment')
  
  increment()
  expect(counterAtom()).toBe(1)
  
  increment()
  expect(counterAtom()).toBe(2)
})

Testing Computed Atoms

Basic Computed

import { test, expect } from 'vitest'
import { atom, computed } from '@reatom/core'

test('computed atom', () => {
  const firstNameAtom = atom('John', 'firstNameAtom')
  const lastNameAtom = atom('Doe', 'lastNameAtom')
  
  const fullNameAtom = computed(
    () => `${firstNameAtom()} ${lastNameAtom()}`,
    'fullNameAtom'
  )
  
  expect(fullNameAtom()).toBe('John Doe')
  
  firstNameAtom.set('Jane')
  expect(fullNameAtom()).toBe('Jane Doe')
})

Memoization Test

import { test, expect, vi } from 'vitest'
import { atom, computed } from '@reatom/core'

test('computed memoization', () => {
  const counterAtom = atom(0, 'counterAtom')
  const computeFn = vi.fn(() => counterAtom() * 2)
  
  const doubledAtom = computed(computeFn, 'doubledAtom')
  
  // First call
  expect(doubledAtom()).toBe(0)
  expect(computeFn).toHaveBeenCalledTimes(1)
  
  // Cached result
  expect(doubledAtom()).toBe(0)
  expect(computeFn).toHaveBeenCalledTimes(1)
  
  // Recompute on dependency change
  counterAtom.set(5)
  expect(doubledAtom()).toBe(10)
  expect(computeFn).toHaveBeenCalledTimes(2)
})

Testing Async Operations

Async Actions

import { test, expect } from 'vitest'
import { action, wrap, noop } from '@reatom/core'
import { withAsync } from '@reatom/core/async'

test('async action', async () => {
  const fetchUser = action(async (id: string) => {
    const res = await wrap(fetch(`/api/users/${id}`))
    return await wrap(res.json())
  }, 'fetchUser').extend(withAsync())
  
  // Initial state
  expect(fetchUser.pending()).toBe(0)
  expect(fetchUser.ready()).toBe(true)
  
  // Start async operation
  const promise = fetchUser('123')
  expect(fetchUser.pending()).toBe(1)
  expect(fetchUser.ready()).toBe(false)
  
  // Wait for completion
  await wrap(promise)
  expect(fetchUser.pending()).toBe(0)
  expect(fetchUser.ready()).toBe(true)
  expect(fetchUser.error()).toBeUndefined()
})

Testing withAsyncData

import { test, expect } from 'vitest'
import { computed, wrap } from '@reatom/core'
import { withAsyncData } from '@reatom/core/async'

test('async data', async () => {
  const userData = computed(async () => {
    return { id: 1, name: 'John' }
  }, 'userData').extend(withAsyncData())
  
  // Initial state
  expect(userData.data()).toBeUndefined()
  expect(userData.ready()).toBe(false)
  
  // Trigger computation
  await wrap(userData())
  
  // Check results
  expect(userData.ready()).toBe(true)
  expect(userData.data()).toEqual({ id: 1, name: 'John' })
})

Error Handling

import { test, expect } from 'vitest'
import { action, wrap, noop } from '@reatom/core'
import { withAsync } from '@reatom/core/async'

test('async error handling', async () => {
  const failingAction = action(async () => {
    throw new Error('Test error')
  }, 'failingAction').extend(withAsync())
  
  await wrap(failingAction()).catch(noop)
  
  expect(failingAction.error()).toBeInstanceOf(Error)
  expect(failingAction.error()?.message).toBe('Test error')
  expect(failingAction.ready()).toBe(true)
})

Testing Forms

Field Validation

import { test, expect } from 'vitest'
import { reatomField } from '@reatom/core/form'
import { wrap, noop } from '@reatom/core'

test('field validation', async () => {
  const emailField = reatomField('', {
    validate: ({ value }) => {
      if (!value.includes('@')) {
        return 'Invalid email'
      }
    },
  })
  
  // Trigger validation
  await wrap(emailField.validation.trigger())
  
  const validation = emailField.validation()
  expect(validation.errors).toHaveLength(1)
  expect(validation.errors[0]?.message).toBe('Invalid email')
  
  // Fix and revalidate
  emailField.change('user@example.com')
  await wrap(emailField.validation.trigger())
  
  expect(emailField.validation().errors).toHaveLength(0)
})

Form Submission

import { test, expect, vi } from 'vitest'
import { reatomForm } from '@reatom/core/form'
import { wrap, noop } from '@reatom/core'

test('form submission', async () => {
  const onSubmit = vi.fn(async (state) => {
    return { success: true }
  })
  
  const form = reatomForm(
    { email: '', password: '' },
    { onSubmit }
  )
  
  // Fill form
  form.fields.email.change('user@example.com')
  form.fields.password.change('secret123')
  
  // Submit
  await wrap(form.submit())
  
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'secret123',
  })
  expect(form.submitted()).toBe(true)
  expect(form.submit.data()).toEqual({ success: true })
})

Schema Validation

import { test, expect } from 'vitest'
import { reatomForm } from '@reatom/core/form'
import { wrap, noop } from '@reatom/core'
import { z } from 'zod'

test('form schema validation', async () => {
  const form = reatomForm(
    { age: 0 },
    {
      schema: z.object({
        age: z.number().min(18, 'Must be 18 or older'),
      }),
    }
  )
  
  form.fields.age.change(16)
  
  // Submit should fail validation
  await wrap(form.submit()).catch(noop)
  
  expect(form.submit.error()).toBeDefined()
  expect(form.fields.age.validation().errors[0]?.message)
    .toBe('Must be 18 or older')
  
  // Fix and resubmit
  form.fields.age.change(25)
  await wrap(form.submit()).catch(noop)
  
  expect(form.submit.error()).toBeUndefined()
})

Testing Routing

Route Matching

import { test, expect } from 'vitest'
import { reatomRoute, urlAtom } from '@reatom/core'

test('route matching', () => {
  const userRoute = reatomRoute('users/:userId')
  
  // No match initially
  expect(userRoute()).toBeNull()
  expect(userRoute.match()).toBe(false)
  
  // Navigate to route
  userRoute.go({ userId: '123' })
  
  // Check match
  expect(userRoute()).toEqual({ userId: '123' })
  expect(userRoute.match()).toBe(true)
  expect(userRoute.exact()).toBe(true)
})

Route Loaders

import { test, expect, vi } from 'vitest'
import { reatomRoute } from '@reatom/core'
import { wrap } from '@reatom/core'

test('route loader', async () => {
  const loadUser = vi.fn(async ({ userId }) => {
    return { id: userId, name: 'John' }
  })
  
  const userRoute = reatomRoute({
    path: 'users/:userId',
    loader: loadUser,
  })
  
  // Navigate to route
  userRoute.go({ userId: '123' })
  
  // Wait for loader
  await wrap(userRoute.loader())
  
  expect(loadUser).toHaveBeenCalledWith({ userId: '123' })
  expect(userRoute.loader.data()).toEqual({ 
    id: '123', 
    name: 'John' 
  })
})

Testing Persistence

LocalStorage Persistence

import { test, expect, beforeEach } from 'vitest'
import { atom } from '@reatom/core'
import { withLocalStorage } from '@reatom/core/persist'

beforeEach(() => {
  localStorage.clear()
})

test('localStorage persistence', () => {
  const counterAtom = atom(0, 'counterAtom').extend(
    withLocalStorage('counter')
  )
  
  // Update atom
  counterAtom.set(5)
  
  // Check localStorage
  const stored = localStorage.getItem('counter')
  expect(stored).toBeDefined()
  
  const record = JSON.parse(stored!)
  expect(record.data).toBe(5)
})

Memory Storage for Tests

import { test, expect } from 'vitest'
import { atom } from '@reatom/core'
import { createMemStorage, reatomPersist } from '@reatom/core/persist'

test('memory storage', () => {
  const storage = createMemStorage({
    name: 'test',
    snapshot: { counter: 10 },
  })
  
  const withTestStorage = reatomPersist(storage)
  
  const counterAtom = atom(0, 'counterAtom').extend(
    withTestStorage('counter')
  )
  
  // Hydrated from snapshot
  expect(counterAtom()).toBe(10)
  
  // Updates storage
  counterAtom.set(20)
  const record = storage.get({ key: 'counter' })
  expect(record?.data).toBe(20)
})

Mocking Dependencies

Mock API Calls

import { test, expect, vi, beforeEach } from 'vitest'
import { action, wrap } from '@reatom/core'
import { withAsync } from '@reatom/core/async'

beforeEach(() => {
  global.fetch = vi.fn()
})

test('mocked fetch', async () => {
  const mockUser = { id: 1, name: 'John' }
  
  vi.mocked(fetch).mockResolvedValueOnce(
    new Response(JSON.stringify(mockUser))
  )
  
  const fetchUser = action(async (id: string) => {
    const res = await wrap(fetch(`/api/users/${id}`))
    return await wrap(res.json())
  }, 'fetchUser').extend(withAsync())
  
  await wrap(fetchUser('1'))
  
  expect(fetch).toHaveBeenCalledWith('/api/users/1')
  expect(fetchUser.data()).toEqual(mockUser)
})

Dependency Injection

import { test, expect, vi } from 'vitest'
import { atom, action } from '@reatom/core'

// Define dependencies as atoms
const apiAtom = atom({
  fetchUser: async (id: string) => {
    const res = await fetch(`/api/users/${id}`)
    return await res.json()
  },
}, 'apiAtom')

const fetchUserAction = action(async (id: string) => {
  const api = apiAtom()
  return await api.fetchUser(id)
}, 'fetchUserAction')

test('dependency injection', async () => {
  // Mock the API
  const mockFetchUser = vi.fn(async () => ({ 
    id: 1, 
    name: 'John' 
  }))
  
  apiAtom.set({
    fetchUser: mockFetchUser,
  })
  
  const result = await fetchUserAction('1')
  
  expect(mockFetchUser).toHaveBeenCalledWith('1')
  expect(result).toEqual({ id: 1, name: 'John' })
})

Testing Side Effects

Effect Testing

import { test, expect, vi } from 'vitest'
import { atom, effect } from '@reatom/core'

test('effect runs on changes', () => {
  const counterAtom = atom(0, 'counterAtom')
  const effectFn = vi.fn(() => {
    counterAtom() // Subscribe
  })
  
  const cleanup = effect(effectFn)
  
  expect(effectFn).toHaveBeenCalledTimes(1)
  
  counterAtom.set(1)
  expect(effectFn).toHaveBeenCalledTimes(2)
  
  counterAtom.set(2)
  expect(effectFn).toHaveBeenCalledTimes(3)
  
  cleanup()
})

Hook Testing

import { test, expect, vi } from 'vitest'
import { atom } from '@reatom/core'
import { withChangeHook } from '@reatom/core'

test('change hook', () => {
  const hook = vi.fn()
  
  const counterAtom = atom(0, 'counterAtom').extend(
    withChangeHook(hook)
  )
  
  counterAtom.set(1)
  expect(hook).toHaveBeenCalledWith(1)
  
  counterAtom.set(2)
  expect(hook).toHaveBeenCalledWith(2)
  
  expect(hook).toHaveBeenCalledTimes(2)
})

Best Practices

1. Test Behavior, Not Implementation

// ✓ Good - tests behavior
test('user can log in', async () => {
  loginForm.fields.email.change('user@example.com')
  loginForm.fields.password.change('password')
  
  await wrap(loginForm.submit())
  
  expect(isLoggedIn()).toBe(true)
})

// ✗ Bad - tests implementation details
test('login sets internal state', async () => {
  expect(loginForm.fields.email.value()).toBe('')
  expect(loginForm.fields.password.value()).toBe('')
  // Too focused on internal details
})

2. Use Real Implementations

// ✓ Good - tests real atom
test('counter increments', () => {
  const counter = atom(0)
  counter.set((n) => n + 1)
  expect(counter()).toBe(1)
})

// ✗ Bad - mocks everything
test('counter increments', () => {
  const counter = { set: vi.fn(), get: vi.fn() }
  // Not testing real code
})

3. Clean Up After Tests

import { beforeEach, afterEach } from 'vitest'

beforeEach(() => {
  localStorage.clear()
  sessionStorage.clear()
})

afterEach(() => {
  vi.clearAllMocks()
})

4. Test Edge Cases

test('handles empty input', () => {
  const field = reatomField('')
  field.change('')
  expect(field.value()).toBe('')
})

test('handles null values', () => {
  const atom = atom<string | null>(null)
  expect(atom()).toBeNull()
})

test('handles async errors', async () => {
  const action = action(async () => {
    throw new Error('Test')
  }).extend(withAsync())
  
  await wrap(action()).catch(noop)
  expect(action.error()).toBeDefined()
})

5. Use Descriptive Test Names

// ✓ Good - clear intent
test('increments counter when increment button is clicked', () => {
  // ...
})

test('shows validation error when email is invalid', async () => {
  // ...
})

// ✗ Bad - vague
test('it works', () => {
  // ...
})

test('test 1', () => {
  // ...
})

Common Patterns

Snapshot Testing

import { test, expect } from 'vitest'
import { atom } from '@reatom/core'

test('state snapshot', () => {
  const state = {
    user: userAtom(),
    settings: settingsAtom(),
    theme: themeAtom(),
  }
  
  expect(state).toMatchSnapshot()
})

Parameterized Tests

import { test, expect } from 'vitest'
import { reatomField } from '@reatom/core/form'

test.each([
  { input: 'test@example.com', valid: true },
  { input: 'invalid', valid: false },
  { input: '', valid: false },
  { input: 'test@', valid: false },
])('validates email: $input', async ({ input, valid }) => {
  const emailField = reatomField('', {
    validate: ({ value }) => {
      if (!value.includes('@')) return 'Invalid email'
    },
  })
  
  emailField.change(input)
  await wrap(emailField.validation.trigger())
  
  const hasErrors = emailField.validation().errors.length > 0
  expect(hasErrors).toBe(!valid)
})

Integration Tests

import { test, expect } from 'vitest'
import { wrap } from '@reatom/core'

test('complete user flow', async () => {
  // Navigate to register
  registerRoute.go()
  
  // Fill form
  registerForm.fields.email.change('user@example.com')
  registerForm.fields.password.change('password123')
  
  // Submit
  await wrap(registerForm.submit())
  
  // Should redirect to dashboard
  expect(dashboardRoute.match()).toBe(true)
  
  // User should be logged in
  expect(currentUser()).toBeDefined()
})