Skip to content

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 },
})
OptionTypeDescription
depsRecord<string, unknown>Mock dependencies injected as context.deps
stateRecord<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).

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"}

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 work
expect(mockApi.search).not.toHaveBeenCalled()

Call abort() before run() to simulate a pre-cancelled state. This tests whether your executor properly respects the abort signal.

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' }),
])
})
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([])
})

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 }),
])
})
save-executor.test.ts
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)
})
})