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-eventBasic 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 transitions →
createActor(machine).start(),actor.send({type}), assertsnapshot.value - Context → assert
snapshot.contextafter transitions - Guards → extract as named functions; test directly without needing a machine actor
- Async actors → mock
fetchor inject mock actors; usevi.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