492 lines
12 KiB
Markdown
492 lines
12 KiB
Markdown
# 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](../../xstate-audition/SKILL.md).
|
|
|
|
## 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
|
|
|
|
## Recommended Testing Library: xstate-audition
|
|
|
|
For comprehensive actor testing, use **[xstate-audition](https://boneskull.github.io/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](../../xstate-audition/SKILL.md) 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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](../../xstate-audition/SKILL.md)** 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
|