Initial commit
This commit is contained in:
491
skills/xstate-v5/references/testing.md
Normal file
491
skills/xstate-v5/references/testing.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user