TestExecutor
TestExecutor runs an executor function in isolation with a mock context. You provide deps and state, run the executor, and assert which events were emitted.
import { TestExecutor } from '@hurum/core/testing'import { SaveExecutor } from './executors'import { PurchaseEvent } from './events'
const mockRepo = { save: vi.fn().mockResolvedValue(savedPurchase),}
const executor = TestExecutor(SaveExecutor, { deps: { repository: mockRepo }, state: { purchase: null, saving: false },})Options
Section titled “Options”| Option | Type | Description |
|---|---|---|
deps | Record<string, unknown> | Mock dependencies injected as context.deps |
state | Record<string, unknown> | Static state returned by context.getState() |
Both are optional. Without them, deps defaults to {} and state defaults to {}.
Execute the executor with the given input:
await executor.run({ id: '123', purchase: mockPurchase })run() returns a Promise that resolves when the executor function completes (including any async work).
assertEmitted
Section titled “assertEmitted”Verify the exact events emitted during execution, in order:
await executor.run({ id: '123' })
executor.assertEmitted([ PurchaseEvent.saveRequested({ id: '123' }), PurchaseEvent.saved({ purchase: savedPurchase }),])Uses structural equality for comparison. Both event types and payloads must match exactly.
If there is a mismatch:
Emitted event mismatch at index 1: Expected: {"type":"Purchase/saved","purchase":{"id":"123"}} Received: {"type":"Purchase/saveFailed","error":"Network error"}emittedEvents
Section titled “emittedEvents”Direct access to the captured events array, for custom assertions:
await executor.run({ id: '123' })
expect(executor.emittedEvents).toHaveLength(2)expect(executor.emittedEvents[0]!.type).toBe('Purchase/saveRequested')Signal cancellation via the AbortSignal:
const executor = TestExecutor(SearchExecutor, { deps: { api: mockApi },})
executor.abort()await executor.run({ query: 'test' })
// Executor should check signal.aborted and skip workexpect(mockApi.search).not.toHaveBeenCalled()Call abort() before run() to simulate a pre-cancelled state. This tests whether your executor properly respects the abort signal.
Common patterns
Section titled “Common patterns”Testing error handling
Section titled “Testing error handling”it('emits saveFailed on error', async () => { const mockRepo = { save: vi.fn().mockRejectedValue(new Error('Timeout')) } const executor = TestExecutor(SaveExecutor, { deps: { repository: mockRepo }, })
await executor.run({ purchase: mockPurchase })
executor.assertEmitted([ PurchaseEvent.saveRequested({ id: '123' }), PurchaseEvent.saveFailed({ error: 'Timeout' }), ])})Testing conditional logic
Section titled “Testing conditional logic”it('skips save when already saving', async () => { const executor = TestExecutor(SaveExecutor, { deps: { repository: mockRepo }, state: { saving: true }, })
await executor.run({ purchase: mockPurchase })
// Executor checks getState().saving and returns early executor.assertEmitted([])})Testing passthrough executors
Section titled “Testing passthrough executors”Even passthrough executors can be tested, though there is rarely a need since they just forward the payload:
it('emits incremented event', async () => { const executor = TestExecutor(IncrementExecutor)
await executor.run({ amount: 5 })
executor.assertEmitted([ CounterEvent.incremented({ amount: 5 }), ])})Full example
Section titled “Full example”import { describe, it, expect, vi } from 'vitest'import { TestExecutor } from '@hurum/core/testing'import { SaveExecutor } from './executors'import { PurchaseEvent } from './events'
describe('SaveExecutor', () => { const mockPurchase = { id: '123', name: 'Widget' }
it('emits saveRequested then saved on success', async () => { const savedPurchase = { ...mockPurchase, savedAt: '2026-01-01' } const executor = TestExecutor(SaveExecutor, { deps: { repository: { save: vi.fn().mockResolvedValue(savedPurchase) } }, })
await executor.run({ purchase: mockPurchase })
executor.assertEmitted([ PurchaseEvent.saveRequested({ id: '123' }), PurchaseEvent.saved({ purchase: savedPurchase }), ]) })
it('emits saveFailed on rejection', async () => { const executor = TestExecutor(SaveExecutor, { deps: { repository: { save: vi.fn().mockRejectedValue(new Error('fail')) } }, })
await executor.run({ purchase: mockPurchase })
executor.assertEmitted([ PurchaseEvent.saveRequested({ id: '123' }), PurchaseEvent.saveFailed({ error: 'fail' }), ]) })
it('does not emit after abort', async () => { const executor = TestExecutor(SaveExecutor, { deps: { repository: { save: vi.fn() } }, })
executor.abort() await executor.run({ purchase: mockPurchase })
// Only the synchronous saveRequested before the first await expect(executor.emittedEvents.length).toBeLessThanOrEqual(1) })})Next steps
Section titled “Next steps”- TestReducer — Test on() handlers as pure functions
- TestStore — Full integration testing