Testing XState v5 Machines: State Transitions, Guards, Actors, and Parallel States

Testing XState v5 Machines: State Transitions, Guards, Actors, and Parallel States

XState v5 models application behavior as explicit state machines. Every state transition is predictable, every guard condition is explicit, and every side effect is an actor. This makes XState code highly testable — if you follow the right patterns.

This guide covers testing XState v5 machines: transition testing, guard testing, actor integration, parallel states, and React component integration.

Setup

npm install xstate @xstate/react
npm install -D @xstate/test vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event

Basic Machine Testing

Define a machine and test its transitions directly:

// machines/authMachine.ts
import { createMachine, assign } from 'xstate'

interface AuthContext {
  user: { id: string; name: string } | null
  error: string | null
}

type AuthEvent =
  | { type: 'LOGIN'; email: string; password: string }
  | { type: 'LOGIN_SUCCESS'; user: { id: string; name: string } }
  | { type: 'LOGIN_FAILURE'; error: string }
  | { type: 'LOGOUT' }

export const authMachine = createMachine({
  id: 'auth',
  initial: 'idle',
  context: {
    user: null,
    error: null,
  } as AuthContext,
  states: {
    idle: {
      on: {
        LOGIN: 'authenticating',
      },
    },
    authenticating: {
      on: {
        LOGIN_SUCCESS: {
          target: 'authenticated',
          actions: assign({
            user: ({ event }) => event.user,
            error: null,
          }),
        },
        LOGIN_FAILURE: {
          target: 'idle',
          actions: assign({
            error: ({ event }) => event.error,
          }),
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: {
          target: 'idle',
          actions: assign({ user: null, error: null }),
        },
      },
    },
  },
})
// machines/authMachine.test.ts
import { createActor } from 'xstate'
import { describe, it, expect } from 'vitest'
import { authMachine } from './authMachine'

describe('authMachine', () => {
  it('starts in idle state', () => {
    const actor = createActor(authMachine)
    actor.start()

    expect(actor.getSnapshot().value).toBe('idle')
    expect(actor.getSnapshot().context.user).toBeNull()
  })

  it('transitions to authenticating on LOGIN', () => {
    const actor = createActor(authMachine)
    actor.start()

    actor.send({ type: 'LOGIN', email: 'alice@example.com', password: 'pass' })

    expect(actor.getSnapshot().value).toBe('authenticating')
  })

  it('transitions to authenticated on LOGIN_SUCCESS', () => {
    const actor = createActor(authMachine)
    actor.start()

    actor.send({ type: 'LOGIN', email: 'alice@example.com', password: 'pass' })
    actor.send({ type: 'LOGIN_SUCCESS', user: { id: 'u1', name: 'Alice Chen' } })

    const snapshot = actor.getSnapshot()
    expect(snapshot.value).toBe('authenticated')
    expect(snapshot.context.user?.name).toBe('Alice Chen')
  })

  it('returns to idle with error on LOGIN_FAILURE', () => {
    const actor = createActor(authMachine)
    actor.start()

    actor.send({ type: 'LOGIN', email: 'alice@example.com', password: 'wrong' })
    actor.send({ type: 'LOGIN_FAILURE', error: 'Invalid credentials' })

    const snapshot = actor.getSnapshot()
    expect(snapshot.value).toBe('idle')
    expect(snapshot.context.error).toBe('Invalid credentials')
  })

  it('clears user and error on LOGOUT', () => {
    const actor = createActor(authMachine, {
      input: {},
      // Start in authenticated state
    }).start()

    // Fast-forward to authenticated state
    actor.send({ type: 'LOGIN', email: 'a@b.com', password: 'p' })
    actor.send({ type: 'LOGIN_SUCCESS', user: { id: 'u1', name: 'Alice' } })

    actor.send({ type: 'LOGOUT' })

    const snapshot = actor.getSnapshot()
    expect(snapshot.value).toBe('idle')
    expect(snapshot.context.user).toBeNull()
  })
})

Testing Guards

Guards are conditions that determine whether a transition is allowed:

// machines/checkoutMachine.ts
import { createMachine, assign } from 'xstate'

interface CheckoutContext {
  items: Array<{ id: string; price: number; quantity: number }>
  total: number
  hasShippingAddress: boolean
  hasPaymentMethod: boolean
}

export const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  context: {
    items: [],
    total: 0,
    hasShippingAddress: false,
    hasPaymentMethod: false,
  } as CheckoutContext,
  states: {
    cart: {
      on: {
        PROCEED_TO_SHIPPING: {
          target: 'shipping',
          guard: ({ context }) => context.items.length > 0,
        },
      },
    },
    shipping: {
      on: {
        SHIPPING_CONFIRMED: {
          target: 'payment',
          actions: assign({ hasShippingAddress: true }),
          guard: ({ context }) => context.hasShippingAddress,
        },
        BACK: 'cart',
      },
    },
    payment: {
      on: {
        PLACE_ORDER: {
          target: 'processing',
          guard: ({ context }) => context.hasShippingAddress && context.hasPaymentMethod,
        },
        BACK: 'shipping',
      },
    },
    processing: {},
    confirmed: {},
    failed: {},
  },
})
// machines/checkoutMachine.test.ts
import { createActor } from 'xstate'
import { describe, it, expect } from 'vitest'
import { checkoutMachine } from './checkoutMachine'

describe('checkoutMachine guards', () => {
  it('blocks PROCEED_TO_SHIPPING when cart is empty', () => {
    const actor = createActor(checkoutMachine).start()

    actor.send({ type: 'PROCEED_TO_SHIPPING' })

    // Guard blocked the transition — still in cart
    expect(actor.getSnapshot().value).toBe('cart')
  })

  it('allows PROCEED_TO_SHIPPING when cart has items', () => {
    const actor = createActor(checkoutMachine, {
      input: {},
    }).start()

    // Add items to context
    actor.getSnapshot().context.items.push({ id: 'p1', price: 79.99, quantity: 1 })

    // Workaround: use initial context
    const actorWithItems = createActor(
      checkoutMachine.provide({
        guards: {},
      }),
      {
        input: {},
      }
    )

    // Better approach: test guards as functions directly
    const guard = ({ context }: { context: typeof checkoutMachine.context }) =>
      context.items.length > 0

    expect(guard({ context: { items: [], total: 0, hasShippingAddress: false, hasPaymentMethod: false } })).toBe(false)
    expect(guard({ context: { items: [{ id: 'p1', price: 10, quantity: 1 }], total: 10, hasShippingAddress: false, hasPaymentMethod: false } })).toBe(true)
  })
})

Better guard testing: extract guards as named functions and test them in isolation:

// machines/checkoutGuards.ts
import type { CheckoutContext } from './checkoutMachine'

export const guards = {
  hasItems: ({ context }: { context: CheckoutContext }) =>
    context.items.length > 0,

  canPlaceOrder: ({ context }: { context: CheckoutContext }) =>
    context.hasShippingAddress && context.hasPaymentMethod,
}
// machines/checkoutGuards.test.ts
import { describe, it, expect } from 'vitest'
import { guards } from './checkoutGuards'

const emptyContext = {
  items: [],
  total: 0,
  hasShippingAddress: false,
  hasPaymentMethod: false,
}

describe('checkout guards', () => {
  describe('hasItems', () => {
    it('returns false for empty cart', () => {
      expect(guards.hasItems({ context: emptyContext })).toBe(false)
    })

    it('returns true when cart has items', () => {
      const context = { ...emptyContext, items: [{ id: 'p1', price: 10, quantity: 1 }] }
      expect(guards.hasItems({ context })).toBe(true)
    })
  })

  describe('canPlaceOrder', () => {
    it('returns false without shipping address', () => {
      const context = { ...emptyContext, hasPaymentMethod: true }
      expect(guards.canPlaceOrder({ context })).toBe(false)
    })

    it('returns false without payment method', () => {
      const context = { ...emptyContext, hasShippingAddress: true }
      expect(guards.canPlaceOrder({ context })).toBe(false)
    })

    it('returns true when both are present', () => {
      const context = { ...emptyContext, hasShippingAddress: true, hasPaymentMethod: true }
      expect(guards.canPlaceOrder({ context })).toBe(true)
    })
  })
})

Testing Actors (Async Services)

XState v5 uses actors for async operations. Mock them in tests:

// machines/loginMachine.ts
import { createMachine, assign, fromPromise } from 'xstate'

interface LoginInput {
  email: string
  password: string
}

async function loginUser(input: LoginInput) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
  if (!res.ok) throw new Error('Login failed')
  return res.json()
}

export const loginMachine = createMachine({
  id: 'login',
  initial: 'idle',
  context: {
    email: '',
    password: '',
    user: null as { id: string; name: string } | null,
    error: null as string | null,
  },
  states: {
    idle: {
      on: { SUBMIT: 'submitting' },
    },
    submitting: {
      invoke: {
        src: fromPromise(({ input }: { input: LoginInput }) => loginUser(input)),
        input: ({ context }) => ({ email: context.email, password: context.password }),
        onDone: {
          target: 'success',
          actions: assign({ user: ({ event }) => event.output }),
        },
        onError: {
          target: 'idle',
          actions: assign({ error: ({ event }) => (event.error as Error).message }),
        },
      },
    },
    success: {},
  },
})

Test actors by providing mock implementations:

// machines/loginMachine.test.ts
import { createActor } from 'xstate'
import { describe, it, expect, vi } from 'vitest'
import { loginMachine } from './loginMachine'

describe('loginMachine', () => {
  it('transitions to success on successful login', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ id: 'u1', name: 'Alice Chen' }),
    } as Response)

    const actor = createActor(loginMachine).start()

    actor.send({ type: 'SUBMIT' })

    await vi.waitFor(() => {
      expect(actor.getSnapshot().value).toBe('success')
    })

    expect(actor.getSnapshot().context.user?.name).toBe('Alice Chen')
  })

  it('returns to idle with error on failed login', async () => {
    global.fetch = vi.fn().mockResolvedValue({ ok: false } as Response)

    const actor = createActor(loginMachine).start()
    actor.send({ type: 'SUBMIT' })

    await vi.waitFor(() => {
      expect(actor.getSnapshot().value).toBe('idle')
    })

    expect(actor.getSnapshot().context.error).toBe('Login failed')
  })
})

Testing Parallel States

Parallel states run concurrently. Verify that each region behaves independently:

// machines/dashboardMachine.ts
import { createMachine } from 'xstate'

export const dashboardMachine = createMachine({
  id: 'dashboard',
  type: 'parallel',
  states: {
    sidebar: {
      initial: 'expanded',
      states: {
        expanded: {
          on: { COLLAPSE_SIDEBAR: 'collapsed' },
        },
        collapsed: {
          on: { EXPAND_SIDEBAR: 'expanded' },
        },
      },
    },
    notifications: {
      initial: 'unread',
      states: {
        unread: {
          on: { MARK_READ: 'read' },
        },
        read: {
          on: { NEW_NOTIFICATION: 'unread' },
        },
      },
    },
  },
})
describe('dashboardMachine parallel states', () => {
  it('initializes both regions correctly', () => {
    const actor = createActor(dashboardMachine).start()
    const state = actor.getSnapshot().value as Record<string, string>

    expect(state.sidebar).toBe('expanded')
    expect(state.notifications).toBe('unread')
  })

  it('collapses sidebar without affecting notifications', () => {
    const actor = createActor(dashboardMachine).start()

    actor.send({ type: 'COLLAPSE_SIDEBAR' })

    const state = actor.getSnapshot().value as Record<string, string>
    expect(state.sidebar).toBe('collapsed')
    expect(state.notifications).toBe('unread')
  })

  it('marks notifications read without affecting sidebar', () => {
    const actor = createActor(dashboardMachine).start()

    actor.send({ type: 'MARK_READ' })

    const state = actor.getSnapshot().value as Record<string, string>
    expect(state.sidebar).toBe('expanded')
    expect(state.notifications).toBe('read')
  })
})

React Component Integration

// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('shows loading state during submission', async () => {
    global.fetch = vi.fn().mockReturnValue(new Promise(() => {})) // Never resolves

    render(<LoginForm />)

    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')
    await userEvent.type(screen.getByLabelText('Password'), 'password')
    await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))

    expect(screen.getByRole('button', { name: 'Signing in…' })).toBeDisabled()
  })

  it('shows error message on failed login', async () => {
    global.fetch = vi.fn().mockResolvedValue({ ok: false } as Response)

    render(<LoginForm />)

    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')
    await userEvent.type(screen.getByLabelText('Password'), 'wrongpass')
    await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Login failed')
    })
  })
})

What Automated Tests Miss

XState machine tests are comprehensive but don't cover:

  • Visual state transitions — animations between states
  • Timeout-based transitions — after clauses in production timing
  • Actor message ordering — race conditions between concurrent actors
  • State chart visualization — verifying the state diagram is correct

HelpMeTest monitors your live app. When a state machine enters an unexpected state in a real browser session, end-to-end tests catch it.

Summary

XState v5 testing strategy:

  • State transitionscreateActor(machine).start(), actor.send({type}), assert snapshot.value
  • Context → assert snapshot.context after transitions
  • Guards → extract as named functions; test directly without needing a machine actor
  • Async actors → mock fetch or inject mock actors; use vi.waitFor() to await async transitions
  • Parallel states → assert each region independently after each event
  • React → test through the UI; assert loading/error/success states by interacting with the component

Read more