Sviatoslav Oleksiv

Testing and Debugging XState in React

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:

Why Testing State Machines is Important

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.

Step 1: Unit Testing XState Machines

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.

Example: Testing a Toggle Machine

Let’s write unit tests for a simple toggle state machine using Jest.

Toggle Machine Definition

import { createMachine } from 'xstate';

export const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: {
      on: { TOGGLE: 'active' }
    },
    active: {
      on: { TOGGLE: 'inactive' }
    }
  }
});

Unit Tests with Jest

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');
  });
});

Explanation:

Step 2: Testing Context and Actions

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.

Counter Machine Definition

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
    })
  }
});

Unit Tests with Context

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);
  });
});

Explanation:

Step 3: Debugging XState Machines

XState provides several tools for debugging, including:

  1. State Logging: You can log transitions and states for quick insights.
  2. XState DevTools: A Chrome extension for visualizing state transitions.
  3. XState Visualizer: An online tool for visualizing state machines.

Logging State Transitions

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'

Using XState DevTools

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();

Installing XState DevTools

To install XState DevTools:

  1. Download the Redux DevTools Extension for Chrome.
  2. Enable devTools: true in your machine’s interpreter.
  3. Open the Redux DevTools in your Chrome Developer Tools to view state transitions.

XState Visualizer

For complex state machines, use the XState Visualizer to visualize your state machine:

  1. Copy your machine definition into the visualizer.
  2. Click on states and events to see transitions in action.
  3. Modify the machine and instantly see how it impacts state flow.

Step 4: Testing Services and Asynchronous Behavior

For machines that include services (e.g., API calls), you should test their behavior with asynchronous operations.

Example: Testing a Fetch Machine

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.

Fetch Machine Definition

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: {}
  }
});

Mocking the Service in Tests

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');
  });
});

Explanation:

Conclusion

In this lesson, we learned how to:

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.