In this lesson, we’ll focus on two critical aspects of building reliable applications with XState: testing and debugging. Proper testing ensures that your state machines behave as expected, while debugging tools help you understand and fix issues during development.
You will learn:
State machines are designed to model complex behavior, making testing an essential part of ensuring that they behave predictably across all possible states and transitions. Since XState follows strict rules for transitions between states, testing a machine is straightforward.
Testing an XState machine involves ensuring that events trigger the expected state transitions and that context updates correctly. For this, you can use any testing library, such as Jest or Mocha.
Let’s write unit tests for a simple toggle state machine using Jest.
import { createMachine } from 'xstate';
export const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' }
},
active: {
on: { TOGGLE: 'inactive' }
}
}
});
import { interpret } from 'xstate';
import { toggleMachine } from './toggleMachine';
describe('toggleMachine', () => {
it('should start in the inactive state', () => {
const toggleService = interpret(toggleMachine).start();
expect(toggleService.state.value).toBe('inactive');
});
it('should transition to active state on TOGGLE event', () => {
const toggleService = interpret(toggleMachine).start();
toggleService.send('TOGGLE');
expect(toggleService.state.value).toBe('active');
});
it('should toggle back to inactive state on TOGGLE event', () => {
const toggleService = interpret(toggleMachine).start();
toggleService.send('TOGGLE'); // to 'active'
toggleService.send('TOGGLE'); // back to 'inactive'
expect(toggleService.state.value).toBe('inactive');
});
});
In the previous tests, we focused on state transitions. However, state machines often involve context and actions. Let’s write tests for a machine that manages context, such as a counter that increments with each event.
import { createMachine, assign } from 'xstate';
export const counterMachine = createMachine({
id: 'counter',
initial: 'active',
context: {
count: 0
},
states: {
active: {
on: {
INCREMENT: {
actions: 'incrementCount'
}
}
}
}
}, {
actions: {
incrementCount: assign({
count: (context) => context.count + 1
})
}
});
import { interpret } from 'xstate';
import { counterMachine } from './counterMachine';
describe('counterMachine', () => {
it('should initialize with a count of 0', () => {
const counterService = interpret(counterMachine).start();
expect(counterService.state.context.count).toBe(0);
});
it('should increment count on INCREMENT event', () => {
const counterService = interpret(counterMachine).start();
counterService.send('INCREMENT');
expect(counterService.state.context.count).toBe(1);
});
it('should increment count multiple times', () => {
const counterService = interpret(counterMachine).start();
counterService.send('INCREMENT');
counterService.send('INCREMENT');
expect(counterService.state.context.count).toBe(2);
});
});
count
in the machine's context.XState provides several tools for debugging, including:
You can log state transitions using the onTransition
function, which runs whenever a transition occurs.
const service = interpret(toggleMachine)
.onTransition((state) => console.log(state.value))
.start();
service.send('TOGGLE'); // Logs 'active'
service.send('TOGGLE'); // Logs 'inactive'
XState also integrates with Redux DevTools or standalone Chrome extensions to visualize state transitions in real-time.
To enable DevTools, simply pass { devTools: true }
when interpreting the machine:
const service = interpret(toggleMachine, { devTools: true }).start();
To install XState DevTools:
devTools: true
in your machine’s interpreter.For complex state machines, use the XState Visualizer to visualize your state machine:
For machines that include services (e.g., API calls), you should test their behavior with asynchronous operations.
Let’s say you have a machine that fetches data from an API. You can simulate and test this asynchronous behavior by mocking the service.
import { createMachine, assign } from 'xstate';
export const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: {
data: null,
error: null
},
states: {
idle: {
on: { FETCH: 'loading' }
},
loading: {
invoke: {
src: 'fetchData',
onDone: {
target: 'success',
actions: assign({ data: (context, event) => event.data })
},
onError: {
target: 'failure',
actions: assign({ error: (context, event) => event.data })
}
}
},
success: {},
failure: {}
}
});
import { interpret } from 'xstate';
import { fetchMachine } from './fetchMachine';
describe('fetchMachine', () => {
it('should transition to success after fetching data', (done) => {
const mockFetch = jest.fn(() =>
Promise.resolve({ data: 'mockData' })
);
const service = interpret(fetchMachine.withConfig({
services: {
fetchData: mockFetch
}
})).onTransition((state) => {
if (state.matches('success')) {
expect(state.context.data).toBe('mockData');
done(); // Mark test as done after success state
}
}).start();
service.send('FETCH');
});
});
fetchData
service, allowing us to simulate API calls.success
state.In this lesson, we learned how to:
onTransition
, DevTools, and the Visualizer to debug machines.Testing and debugging are essential for building reliable applications, and XState's structure makes this process easier by providing strict state transition rules and tools to visualize and inspect machine behavior.
In the next lesson, we will explore best practices for optimizing performance in XState machines and handling large-scale applications.