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

12 KiB

description, triggers
description triggers
Expert guidance on testing XState v5 Actors using xstate-audition library for comprehensive state machine and actor testing
xstate-audition
testing state machines
testing actors
actor testing
state machine testing
xstate test
runUntilDone
waitForSnapshot

XState Audition Testing Skill

You are an expert in testing XState v5 Actors using xstate-audition, a dependency-free library for testing XState actor behavior. Use this knowledge to guide test implementation with best practices for state machine and actor testing.

When to Use xstate-audition

Use xstate-audition when:

  • Testing XState v5 Actors (state machines, promise actors, callback actors, etc.)
  • Verifying state transitions and actor lifecycle behavior
  • Testing event handling, emissions, and inter-actor communication
  • Validating actor spawning and hierarchical actor systems
  • Testing asynchronous actor behavior with timeouts
  • Comprehensive actor integration testing

Do NOT use xstate-audition for:

  • Unit testing pure functions (guards, actions in isolation)
  • Testing UI components directly (use framework-specific testing tools)
  • XState v4 actors (this library is v5-only)

Core Testing Pattern

All xstate-audition functions follow this pattern:

  1. Create an Actor using createActor(logic)
  2. Create a Promise<T> using a condition function (e.g., runUntilDone(actor))
  3. If the actor needs external input, perform that operation before awaiting
  4. await the Promise<T>
  5. Make assertions about T
import { createActor } from 'xstate';
import { runUntilDone } from 'xstate-audition';

const actor = createActor(myMachine);
const result = await runUntilDone(actor); // starts and runs to completion
assert.equal(result, expectedOutput);

Key Functions Overview

runUntil*() vs waitFor*()

  • runUntil*(): Starts the actor, waits for condition, then stops the actor
  • waitFor*(): Starts the actor, waits for condition, but keeps the actor running

Use runUntil*() for isolated tests, waitFor*() when you need to continue testing.

Function Categories

  1. Completion Testing: runUntilDone() - Wait for final state
  2. Emission Testing: runUntilEmitted() - Wait for event emissions
  3. Transition Testing: runUntilTransition() - Wait for specific state changes
  4. Snapshot Testing: runUntilSnapshot() - Wait for snapshot predicate
  5. Spawn Testing: runUntilSpawn() - Wait for child actor spawning
  6. Event Testing: runUntilEventReceived(), runUntilEventSent() - Inter-actor communication

Common Testing Patterns

Pattern 1: Testing Promise Actors

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

const promiseLogic = fromPromise<string, string>(async ({ input }) => {
  return `hello ${input}`;
});

it('should complete with expected output', async () => {
  const actor = createActor(promiseLogic, { input: 'world' });
  const result = await runUntilDone(actor);

  assert.equal(result, 'hello world');
});

Pattern 2: Testing State Transitions

import { runUntilTransition } from 'xstate-audition';

it('should transition from idle to loading', async () => {
  const actor = createActor(fetchMachine);

  // Curried form for reusability
  const waitFromIdle = runUntilTransition(actor, 'fetchMachine.idle');

  actor.send({ type: 'FETCH' });
  await waitFromIdle('fetchMachine.loading');
});

Pattern 3: Testing Event Emissions

import { emit, setup } from 'xstate';
import { runUntilEmitted } from 'xstate-audition';

const emitterMachine = setup({
  types: {
    emitted: {} as { type: 'READY'; value: string },
  },
}).createMachine({
  entry: emit({ type: 'READY', value: 'initialized' }),
});

it('should emit READY event on entry', async () => {
  const actor = createActor(emitterMachine);
  const [event] = await runUntilEmitted(actor, ['READY']);

  assert.deepEqual(event, { type: 'READY', value: 'initialized' });
});

Pattern 4: Testing with External Input

When actors need events to satisfy conditions:

import { waitForSpawn } from 'xstate-audition';

it('should spawn child when event received', async () => {
  const actor = createActor(spawnerMachine);

  // Setup the promise BEFORE sending the event
  const promise = waitForSpawn(actor, 'childId');

  // Now send the event that triggers spawning
  actor.send({ type: 'SPAWN' });

  // Finally await the result
  const childRef = await promise;
  assert.equal(childRef.id, 'childId');
});

Pattern 5: Testing Snapshot Predicates

import { runUntilSnapshot } from 'xstate-audition';

it('should reach error state with error in context', async () => {
  const actor = createActor(fetchMachine);

  actor.send({ type: 'FETCH', url: 'invalid' });

  const snapshot = await runUntilSnapshot(
    actor,
    (snapshot) => snapshot.matches('error') && snapshot.context.error !== null,
  );

  assert.ok(snapshot.context.error);
  assert.equal(snapshot.value, 'error');
});

Using AuditionOptions

All functions ending in With() accept AuditionOptions as the second parameter:

import { runUntilDoneWith } from 'xstate-audition';

it('should timeout if takes too long', async () => {
  const actor = createActor(slowMachine);

  await assert.rejects(
    runUntilDoneWith(actor, { timeout: 100 }), // 100ms timeout
    (err: Error) => {
      assert.match(err.message, /did not complete in 100ms/);
      return true;
    },
  );
});

AuditionOptions Properties

  • timeout (default: 1000ms): Maximum wait time. Set to 0, negative, or Infinity to disable.
  • logger (default: no-op): Custom logger function for debugging.
  • inspector: Custom inspector callback or observer for actor events.

Currying Pattern

All functions are curried for reusability:

import { runUntilTransition } from 'xstate-audition';

describe('stateMachine', () => {
  let actor: Actor<typeof machine>;
  let runFromIdle: CurryTransitionP2<typeof actor>;

  beforeEach(() => {
    actor = createActor(machine);
    // Curry with actor and fromState
    runFromIdle = runUntilTransition(actor, 'machine.idle');
  });

  it('should transition to loading', async () => {
    actor.send({ type: 'FETCH' });
    await runFromIdle('machine.loading');
  });

  it('should transition to success', async () => {
    actor.send({ type: 'FETCH' });
    actor.send({ type: 'SUCCESS' });
    await runFromIdle('machine.success');
  });
});

Best Practices

DO

  1. Use currying for repeated patterns - Reduce boilerplate in test suites
  2. Set external input before awaiting - Avoid race conditions
  3. Use waitFor*() for multi-stage tests - Keep actor alive for sequential assertions
  4. Provide explicit type arguments - Especially for runUntilSpawn() to get correct types
  5. Use appropriate timeouts - Set timeout less than test framework timeout
  6. Test state transitions explicitly - Use runUntilTransition() for clear intent
  7. Use runUntilSnapshot() for complex conditions - When multiple conditions must be met

DON'T

  1. Don't await immediately for event-driven actors - Setup promise, send event, then await
  2. Don't use xstate-audition for unit testing - Test guards and actions separately
  3. Don't rely on default timeout - Be explicit when tests are expected to be slow
  4. Don't mix v4 and v5 - This library is v5-only
  5. Don't forget type parameters - Especially for spawned actors
  6. Don't test UI directly - Use framework-specific testing tools
  7. Don't use string event types - Use typed event objects for better type safety

Testing Hierarchical Actors

When testing parent/child actor systems:

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

it('should spawn child and communicate', async () => {
  const parent = createActor(parentMachine);

  // Wait for child to spawn
  const child = await waitForSpawn<typeof childLogic>(parent, 'childActor');

  // Parent still running, send event to child
  child.send({ type: 'CHILD_EVENT' });

  // Wait for parent to react to child's output
  await waitForSnapshot(parent, (snapshot) =>
    snapshot.matches('parentReacted'),
  );
});

Integration with Test Frameworks

Node.js test (built-in)

import { describe, it, beforeEach } from 'node:test';
import { strict as assert } from 'node:assert';

describe('myMachine', () => {
  let actor: Actor<typeof myMachine>;

  beforeEach(() => {
    actor = createActor(myMachine);
  });

  it('should complete successfully', async () => {
    const result = await runUntilDone(actor);
    assert.equal(result, 'expected');
  });
});

Jest/Vitest

import { describe, it, beforeEach, expect } from 'vitest';

describe('myMachine', () => {
  it('should reach error state', async () => {
    const actor = createActor(myMachine);
    actor.send({ type: 'ERROR' });

    const snapshot = await runUntilSnapshot(actor, (s) => s.matches('error'));
    expect(snapshot.context.error).toBeDefined();
  });
});

Debugging Failed Tests

When tests fail or timeout:

  1. Use custom logger: Pass { logger: console.log } to see actor activity
  2. Use inspector: Pass { inspector: (event) => console.log(event) } for detailed events
  3. Increase timeout: Start with longer timeout to identify if it's a timing issue
  4. Check state IDs: Ensure state IDs match (use machine ID prefix)
  5. Verify event types: Ensure event type strings match exactly
  6. Test incrementally: Break complex tests into smaller steps
import { runUntilSnapshotWith } from 'xstate-audition';

const snapshot = await runUntilSnapshotWith(
  actor,
  {
    logger: console.log,
    timeout: 5000,
    inspector: (event) => console.log('Inspector:', event),
  },
  (snapshot) => snapshot.matches('targetState'),
);

Reference Documentation

For detailed API documentation, see the references directory:

Common Mistakes

  1. Awaiting before sending required event:

    // ❌ WRONG - promise created but never satisfied
    const promise = waitForSnapshot(actor, (s) => s.matches('done'));
    await promise; // hangs forever, no event sent!
    
    // ✅ CORRECT
    const promise = waitForSnapshot(actor, (s) => s.matches('done'));
    actor.send({ type: 'COMPLETE' }); // send before await
    await promise;
    
  2. Wrong state ID format:

    // ❌ WRONG - missing machine ID prefix
    await runUntilTransition(actor, 'idle', 'loading');
    
    // ✅ CORRECT - include machine ID
    await runUntilTransition(actor, 'myMachine.idle', 'myMachine.loading');
    
  3. Not providing type parameters for spawn:

    // ❌ WRONG - type is AnyActorRef (not useful)
    const child = await runUntilSpawn(actor, 'childId');
    // ✅ CORRECT - explicit type
    const child = await runUntilSpawn<typeof childLogic>(actor, 'childId');
    

When implementing tests with xstate-audition:

  • Start with simple runUntilDone() tests for basic actor behavior
  • Use specific condition functions (runUntilTransition, runUntilEmitted) for targeted tests
  • Leverage currying to reduce test boilerplate
  • Always consider timing - setup promises before triggering conditions
  • Use TypeScript types for better test reliability

Remember: xstate-audition excels at testing actor behavior and interactions. It complements (not replaces) unit testing of individual guards, actions, and services.