Files
gh-boneskull-claude-plugins…/skills/xstate-v5/references/testing.md
2025-11-29 18:01:45 +08:00

12 KiB

XState v5 Testing Reference (Backend Focus)

This reference covers testing XState v5 actors and state machines for backend applications. For comprehensive actor testing patterns, see the xstate-audition skill.

Testing Strategy Overview

XState v5 testing for backend applications focuses on:

  • Actor lifecycle and behavior testing
  • State transition verification
  • Event handling and inter-actor communication
  • Asynchronous operation testing
  • Error handling and recovery

For comprehensive actor testing, use xstate-audition, which provides:

  • Dependency-free XState v5 actor testing
  • Async-first testing utilities
  • Comprehensive actor lifecycle testing
  • Built-in timeout handling
  • Curried functions for test reusability

See the xstate-audition skill documentation for detailed usage patterns.

Basic Unit Testing

While xstate-audition excels at actor testing, you may need to unit test individual pieces:

Testing Guards in Isolation

import { describe, test, expect } from 'vitest';

// Guard function
const isValidThreshold = ({ context }: { context: { value: number } }) =>
  context.value > 10;

describe('guards', () => {
  test('isValidThreshold returns true for values > 10', () => {
    expect(isValidThreshold({ context: { value: 15 } })).toBe(true);
    expect(isValidThreshold({ context: { value: 5 } })).toBe(false);
  });
});

Testing Action Functions

import { vi, describe, test, expect } from 'vitest';

// Action function
const logMessage = ({ context, event }: any) => {
  console.log(`Event: ${event.type}, Count: ${context.count}`);
};

test('logMessage action logs correctly', () => {
  const consoleSpy = vi.spyOn(console, 'log');

  logMessage({
    context: { count: 5 },
    event: { type: 'TEST_EVENT' },
  });

  expect(consoleSpy).toHaveBeenCalledWith('Event: TEST_EVENT, Count: 5');
});

Backend-Specific Testing Patterns

Testing Database Transaction States

import { createActor } from 'xstate';
import { runUntilDone, waitForSnapshot } from 'xstate-audition';

const dbTransactionMachine = createMachine({
  initial: 'idle',
  states: {
    idle: {
      on: { BEGIN: 'transaction' },
    },
    transaction: {
      initial: 'processing',
      states: {
        processing: {
          invoke: {
            src: 'executeQuery',
            onDone: 'committing',
            onError: 'rollingBack',
          },
        },
        committing: {
          invoke: {
            src: 'commitTransaction',
            onDone: '#db.success',
            onError: '.rollingBack',
          },
        },
        rollingBack: {
          invoke: {
            src: 'rollbackTransaction',
            onDone: '#db.failed',
          },
        },
      },
    },
    success: { type: 'final' },
    failed: { type: 'final' },
  },
});

test('successful database transaction', async () => {
  const actor = createActor(dbTransactionMachine);
  const promise = waitForSnapshot(actor, (s) => s.matches('success'));

  actor.send({ type: 'BEGIN' });
  await promise;

  expect(actor.getSnapshot().value).toBe('success');
});

Testing Message Queue Processing

const queueProcessorMachine = createMachine({
  context: {
    messages: [],
    processed: 0,
    failed: 0,
  },
  initial: 'polling',
  states: {
    polling: {
      invoke: {
        src: 'pollQueue',
        onDone: {
          target: 'processing',
          actions: assign({
            messages: ({ event }) => event.output,
          }),
        },
      },
    },
    processing: {
      always: [
        {
          target: 'idle',
          guard: ({ context }) => context.messages.length === 0,
        },
        {
          target: 'processingMessage',
        },
      ],
    },
    processingMessage: {
      invoke: {
        src: 'processMessage',
        input: ({ context }) => context.messages[0],
        onDone: {
          target: 'processing',
          actions: assign({
            messages: ({ context }) => context.messages.slice(1),
            processed: ({ context }) => context.processed + 1,
          }),
        },
        onError: {
          target: 'processing',
          actions: assign({
            messages: ({ context }) => context.messages.slice(1),
            failed: ({ context }) => context.failed + 1,
          }),
        },
      },
    },
    idle: {
      after: {
        5000: 'polling',
      },
    },
  },
});

test('processes queue messages', async () => {
  const actor = createActor(queueProcessorMachine);

  await waitForSnapshot(actor, (snapshot) => snapshot.context.processed >= 3);

  expect(actor.getSnapshot().context.failed).toBe(0);
});

Testing API Rate Limiting States

const rateLimiterMachine = createMachine({
  context: {
    requestCount: 0,
    resetTime: null,
  },
  initial: 'ready',
  states: {
    ready: {
      on: {
        REQUEST: [
          {
            target: 'throttled',
            guard: ({ context }) => context.requestCount >= 100,
            actions: assign({
              resetTime: () => Date.now() + 60000,
            }),
          },
          {
            target: 'processing',
            actions: assign({
              requestCount: ({ context }) => context.requestCount + 1,
            }),
          },
        ],
      },
    },
    processing: {
      invoke: {
        src: 'handleRequest',
        onDone: 'ready',
        onError: 'ready',
      },
    },
    throttled: {
      after: {
        60000: {
          target: 'ready',
          actions: assign({
            requestCount: 0,
            resetTime: null,
          }),
        },
      },
    },
  },
});

test('enforces rate limits', async () => {
  const actor = createActor(rateLimiterMachine);

  // Send 100 requests
  for (let i = 0; i < 100; i++) {
    actor.send({ type: 'REQUEST' });
  }

  // 101st request should be throttled
  actor.send({ type: 'REQUEST' });

  await waitForSnapshot(actor, (s) => s.matches('throttled'));
  expect(actor.getSnapshot().context.resetTime).toBeDefined();
});

Testing with Mock Services

Mocking External Services

import { vi } from 'vitest';
import { fromPromise } from 'xstate';

test('handles service failures gracefully', async () => {
  const mockService = vi
    .fn()
    .mockRejectedValue(new Error('Service unavailable'));

  const machine = setup({
    actors: {
      externalService: fromPromise(mockService),
    },
  }).createMachine({
    initial: 'calling',
    states: {
      calling: {
        invoke: {
          src: 'externalService',
          onError: {
            target: 'fallback',
            actions: assign({
              error: ({ event }) => event.error.message,
            }),
          },
        },
      },
      fallback: {
        type: 'final',
      },
    },
  });

  const actor = createActor(machine);
  const result = await runUntilDone(actor);

  expect(actor.getSnapshot().context.error).toBe('Service unavailable');
  expect(mockService).toHaveBeenCalledTimes(1);
});

Testing Async Behavior

Using waitFor from xstate

import { waitFor } from 'xstate';

test('waits for async completion', async () => {
  const actor = createActor(asyncMachine);
  actor.start();

  actor.send({ type: 'START_ASYNC' });

  const snapshot = await waitFor(
    actor,
    (snapshot) => snapshot.context.isComplete === true,
    { timeout: 5000 },
  );

  expect(snapshot.context.result).toBeDefined();
});

Testing with Fake Timers

import { vi, beforeEach, afterEach } from 'vitest';

describe('delayed transitions', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  test('transitions after delay', () => {
    const actor = createActor(delayedMachine);
    actor.start();

    expect(actor.getSnapshot().value).toBe('waiting');

    vi.advanceTimersByTime(5000);

    expect(actor.getSnapshot().value).toBe('ready');
  });
});

Testing Actor Communication

Testing Parent-Child Actors

import { spawn, sendTo } from 'xstate';
import { runUntilSpawn, waitForSnapshot } from 'xstate-audition';

test('parent spawns and communicates with child', async () => {
  const parentMachine = createMachine({
    initial: 'idle',
    context: {
      childResponse: null,
    },
    states: {
      idle: {
        on: {
          START: {
            actions: spawn(childMachine, { id: 'child' }),
            target: 'waiting',
          },
        },
      },
      waiting: {
        on: {
          CHILD_RESPONSE: {
            actions: assign({
              childResponse: ({ event }) => event.data,
            }),
            target: 'done',
          },
        },
        entry: sendTo('child', { type: 'PROCESS' }),
      },
      done: { type: 'final' },
    },
  });

  const actor = createActor(parentMachine);
  const childRef = await runUntilSpawn(actor, 'child');

  await waitForSnapshot(actor, (s) => s.matches('done'));

  expect(actor.getSnapshot().context.childResponse).toBeDefined();
});

Performance Testing

test('handles high throughput', async () => {
  const start = performance.now();
  const actor = createActor(highThroughputMachine);

  actor.start();

  // Send many events rapidly
  for (let i = 0; i < 10000; i++) {
    actor.send({ type: 'PROCESS', data: i });
  }

  await waitFor(actor, (s) => s.context.processedCount === 10000);

  const duration = performance.now() - start;
  expect(duration).toBeLessThan(5000); // Should process within 5 seconds
});

Error Handling Tests

test('recovers from errors with retry', async () => {
  let attempts = 0;
  const mockService = vi.fn().mockImplementation(() => {
    attempts++;
    if (attempts < 3) {
      throw new Error('Temporary failure');
    }
    return { success: true };
  });

  const retryMachine = createMachine({
    context: { retries: 0, maxRetries: 3 },
    initial: 'attempting',
    states: {
      attempting: {
        invoke: {
          src: fromPromise(mockService),
          onDone: 'success',
          onError: [
            {
              target: 'attempting',
              guard: ({ context }) => context.retries < context.maxRetries,
              actions: assign({
                retries: ({ context }) => context.retries + 1,
              }),
            },
            {
              target: 'failed',
            },
          ],
        },
      },
      success: { type: 'final' },
      failed: { type: 'final' },
    },
  });

  const actor = createActor(retryMachine);
  await runUntilDone(actor);

  expect(actor.getSnapshot().value).toBe('success');
  expect(mockService).toHaveBeenCalledTimes(3);
});

Test Organization Best Practices

  1. Separate unit tests from integration tests - Test guards and actions separately from full actor behavior
  2. Use xstate-audition for actor tests - Leverage its powerful async utilities
  3. Mock external dependencies - Database, APIs, message queues
  4. Test error paths explicitly - Ensure graceful degradation
  5. Use descriptive test names - Clearly indicate what behavior is being tested
  6. Keep tests focused - One behavior per test
  7. Use fake timers for time-dependent logic - Control time progression explicitly

Summary

For backend XState v5 testing:

  • Use xstate-audition for comprehensive actor testing
  • Focus on actor behavior, not UI interactions
  • Test database transactions, message processing, and API interactions
  • Mock external services appropriately
  • Handle async behavior with proper utilities
  • Ensure error recovery and retry logic works correctly