Skip to main content

Testing Workflows

Comprehensive testing ensures workflows behave correctly under all conditions. This guide covers unit testing, integration testing, and CI/CD integration.

Unit Testing

Unit tests verify individual components in isolation.

Testing Step Handlers

Test step handlers without starting full workflows:

// src/modules/workflows/lib/step-handlers/__tests__/user-task.test.ts
import { executeUserTaskStep } from '../user-task'
import { mock, MockProxy } from 'jest-mock-extended'
import { TaskService } from '../../task-service'

describe('executeUserTaskStep', () => {
let taskService: MockProxy<TaskService>

beforeEach(() => {
taskService = mock<TaskService>()
})

it('should create user task', async () => {
const definition = {
stepId: 'approve-request',
stepName: 'Approve Request',
stepType: 'USER_TASK',
userTaskConfig: {
assignedToRoles: ['Approvers'],
formSchema: [
{ fieldName: 'decision', fieldType: 'select', required: true }
]
}
}

const context = {
workflowInstanceId: 'wf-inst-123',
stepInstanceId: 'step-inst-456',
workflowContext: { orderId: 'order-123' },
taskService,
container: {}
}

taskService.createTask.mockResolvedValue({
id: 'task-789',
status: 'PENDING'
} as any)

const result = await executeUserTaskStep(definition, context as any)

expect(result.success).toBe(true)
expect(taskService.createTask).toHaveBeenCalledWith(
expect.objectContaining({
workflowInstanceId: 'wf-inst-123',
taskName: 'Approve Request'
})
)
})
})

Mocking Activities

Mock activity executors to test transition logic:

// src/modules/workflows/__tests__/transition-activities.test.ts
import { executeActivity } from '../lib/activity-executor'
import { activityExecutors } from '../lib/activity-executor'

jest.mock('../lib/activities/send-email', () => ({
executeSendEmailActivity: jest.fn()
}))

describe('Transition activities', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('should execute activity on transition', async () => {
const mockExecutor = activityExecutors.SEND_EMAIL as jest.Mock
mockExecutor.mockResolvedValue({
success: true,
output: { messageId: 'msg-123' }
})

const definition = {
activityId: 'send-email',
activityType: 'SEND_EMAIL',
config: {
subject: 'Test',
body: 'Test message'
}
}

const context = {
workflowContext: {},
container: {}
}

const result = await executeActivity(definition, context as any)

expect(result.success).toBe(true)
expect(mockExecutor).toHaveBeenCalled()
})
})

Validating State Transitions

Test transition condition evaluation:

// src/modules/workflows/lib/__tests__/condition-evaluator.test.ts
import { evaluateConditions } from '../condition-evaluator'

describe('evaluateConditions', () => {
it('should evaluate simple expression', async () => {
const conditions = [
{
ruleId: 'amount-check',
expression: 'context.amount > 1000'
}
]

const context = { amount: 1500 }
const result = await evaluateConditions(conditions, context, {})

expect(result).toBe(true)
})

it('should return false for failing condition', async () => {
const conditions = [
{
ruleId: 'amount-check',
expression: 'context.amount > 1000'
}
]

const context = { amount: 500 }
const result = await evaluateConditions(conditions, context, {})

expect(result).toBe(false)
})
})

Integration Testing

Integration tests verify entire workflow executions.

Starting Workflow Instances

Test workflows from start to completion:

// src/modules/workflows/__tests__/approval-workflow.integration.test.ts
import { WorkflowService } from '../lib/workflow-service'
import { createTestContainer } from '@/test/test-container'
import { EntityManager } from '@mikro-orm/core'
import { UserTask } from '@saasframe/core/modules/workflows/data/entities'

describe('Approval Workflow Integration', () => {
let workflowService: WorkflowService
let em: EntityManager
let container: any

beforeAll(async () => {
container = await createTestContainer()
workflowService = container.resolve('workflowService')
em = container.resolve('em')
})

afterEach(async () => {
await em.nativeDelete('workflow_instances', {})
await em.nativeDelete('user_tasks', {})
})

it('should complete simple approval workflow', async () => {
// Start workflow
const instance = await workflowService.startWorkflow({
workflowId: 'simple-approval-v1',
initialContext: {
orderId: 'order-123',
amount: 500,
requesterEmail: '[email protected]',
approverEmail: '[email protected]'
}
})

expect(instance.status).toBe('RUNNING')
expect(instance.currentStepId).toBe('start')

// Wait for workflow to advance to user task
await new Promise(resolve => setTimeout(resolve, 100))

// Reload instance
const updated = await workflowService.getInstance(instance.id)
expect(updated.currentStepId).toBe('approve-request')

// Find created task
const tasks = await em.find(UserTask, {
workflowInstanceId: instance.id,
status: 'PENDING'
})

expect(tasks).toHaveLength(1)

// Complete task
const task = tasks[0]
await workflowService.completeTask(task.id, {
decision: 'approve',
comments: 'Approved'
})

// Workflow should complete
await new Promise(resolve => setTimeout(resolve, 100))

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
expect(final.context.decision).toBe('approve')
})
})

Simulating Task Completion

Test user task flows:

import { UserTask } from '@saasframe/core/modules/workflows/data/entities'

it('should handle task rejection', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'simple-approval-v1',
initialContext: { orderId: 'order-123', amount: 1000 }
})

// Advance to user task
await waitForStep(instance.id, 'approve-request')

// Get task
const tasks = await em.find(UserTask, {
workflowInstanceId: instance.id,
status: 'PENDING'
})

// Reject task
await workflowService.completeTask(tasks[0].id, {
decision: 'reject',
comments: 'Insufficient budget'
})

// Workflow should reach rejected end state
await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
expect(final.currentStepId).toBe('end-rejected')
})

Sending Signals

Test signal-based workflows:

it('should resume workflow on signal', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'payment-workflow-v1',
initialContext: { orderId: 'order-123', amount: 150 },
correlationKey: 'order-123'
})

// Workflow should wait for payment signal
await waitForStep(instance.id, 'wait-for-payment')

const current = await workflowService.getInstance(instance.id)
expect(current.status).toBe('WAITING_FOR_SIGNAL')

// Send payment signal
await workflowService.sendSignal(instance.id, {
signalName: 'payment-confirmed',
payload: {
transactionId: 'txn-abc123',
amount: 150
}
})

// Workflow should resume and complete
await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
expect(final.context.transactionId).toBe('txn-abc123')
})

Verifying Final State

Check workflow outcomes and context:

it('should merge activity outputs into context', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'notification-workflow-v1',
initialContext: { customerEmail: '[email protected]' }
})

await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')

// Verify activity outputs
expect(final.context.activities['send-email'].output.messageId).toBeDefined()
expect(final.context.activities['send-email'].output.sentAt).toBeDefined()
})

Testing Async Activities

Test async activity execution with test queues:

// src/modules/workflows/__tests__/async-activities.integration.test.ts
import { WorkflowService } from '../lib/workflow-service'
import { ActivityQueue } from '../lib/activity-queue'

describe('Async Activities', () => {
let workflowService: WorkflowService
let activityQueue: ActivityQueue

beforeAll(async () => {
const container = await createTestContainer()
workflowService = container.resolve('workflowService')
activityQueue = container.resolve('activityQueue')
})

it('should execute async activity in background', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'async-workflow-v1',
initialContext: { reportId: 'report-123' }
})

// Workflow should enter WAITING_FOR_ACTIVITIES state
await new Promise(resolve => setTimeout(resolve, 100))

const waiting = await workflowService.getInstance(instance.id)
expect(waiting.status).toBe('WAITING_FOR_ACTIVITIES')

// Process activity queue
await activityQueue.processAll()

// Workflow should complete
await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
})
})

Testing Compensation

Test saga pattern and rollback:

import { WorkflowEvent } from '@saasframe/core/modules/workflows/data/entities'

it('should trigger compensation on failure', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'saga-workflow-v1',
initialContext: { orderId: 'order-123' }
})

// Mock activity to fail
jest.spyOn(activityExecutors, 'CALL_API').mockResolvedValueOnce({
success: true,
output: { inventoryReserved: true }
}).mockResolvedValueOnce({
success: false,
error: 'Payment gateway unavailable'
})

// Wait for failure
await waitForFailure(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('FAILED')

// Verify compensation activity was executed
const events = await em.find(WorkflowEvent, {
workflowInstanceId: instance.id,
eventType: 'ACTIVITY_COMPENSATED'
})

expect(events).toHaveLength(1)
expect(events[0].eventData.activityId).toBe('reserve-inventory')
})

Example Test Suite

Complete test suite for an approval workflow:

// src/modules/workflows/__tests__/purchase-approval.test.ts
import { WorkflowService } from '../lib/workflow-service'
import { createTestContainer } from '@/test/test-container'
import { EntityManager } from '@mikro-orm/core'
import { UserTask } from '@saasframe/core/modules/workflows/data/entities'

describe('Purchase Approval Workflow', () => {
let workflowService: WorkflowService
let em: EntityManager
let container: any

beforeAll(async () => {
container = await createTestContainer()
workflowService = container.resolve('workflowService')
em = container.resolve('em')
})

afterEach(async () => {
await em.nativeDelete('workflow_instances', {})
await em.nativeDelete('user_tasks', {})
})

describe('Auto-approval path', () => {
it('should auto-approve requests under $1000', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'purchase-approval-v1',
initialContext: { orderId: 'order-123', amount: 500 }
})

await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
expect(final.currentStepId).toBe('end-approved')
})
})

describe('Manager approval path', () => {
it('should require manager approval for $1000-$10000', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'purchase-approval-v1',
initialContext: { orderId: 'order-123', amount: 5000 }
})

await waitForStep(instance.id, 'manager-approval')

const tasks = await em.find(UserTask, {
workflowInstanceId: instance.id
})

expect(tasks).toHaveLength(1)
expect(tasks[0].assignedToRoles).toContain('Managers')

await workflowService.completeTask(tasks[0].id, {
decision: 'approve',
comments: 'Approved by manager'
})

await waitForCompletion(instance.id)

const final = await workflowService.getInstance(instance.id)
expect(final.status).toBe('COMPLETED')
expect(final.currentStepId).toBe('end-approved')
})
})

describe('Director approval path', () => {
it('should require director approval for amounts over $10000', async () => {
const instance = await workflowService.startWorkflow({
workflowId: 'purchase-approval-v1',
initialContext: { orderId: 'order-123', amount: 15000 }
})

await waitForStep(instance.id, 'director-approval')

const tasks = await em.find(UserTask, {
workflowInstanceId: instance.id
})

expect(tasks).toHaveLength(1)
expect(tasks[0].assignedToRoles).toContain('Directors')
})
})
})

// Helper functions
async function waitForStep(instanceId: string, stepId: string, timeout = 5000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const instance = await workflowService.getInstance(instanceId)
if (instance.currentStepId === stepId) {
return instance
}
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error(`Timeout waiting for step ${stepId}`)
}

async function waitForCompletion(instanceId: string, timeout = 5000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const instance = await workflowService.getInstance(instanceId)
if (instance.status === 'COMPLETED') {
return instance
}
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error('Timeout waiting for completion')
}

async function waitForFailure(instanceId: string, timeout = 5000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const instance = await workflowService.getInstance(instanceId)
if (instance.status === 'FAILED') {
return instance
}
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error('Timeout waiting for failure')
}

CI/CD Integration

Running Tests

Add workflow tests to your CI pipeline:

# .github/workflows/test.yml
name: Test Workflows

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'

- name: Install dependencies
run: npm install

- name: Run workflow tests
run: yarn test:workflows

- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/workflows/lcov.info

Test Scripts

// package.json
{
"scripts": {
"test": "jest",
"test:workflows": "jest --testPathPattern=workflows",
"test:workflows:watch": "jest --watch --testPathPattern=workflows",
"test:workflows:coverage": "jest --coverage --testPathPattern=workflows"
}
}

Best Practices

For Unit Tests

  • Test one component at a time
  • Mock external dependencies (services, APIs, databases)
  • Use descriptive test names (e.g., "should create user task with correct assignment")
  • Test both happy paths and error scenarios

For Integration Tests

  • Use test containers for database isolation
  • Clean up test data after each test
  • Test realistic workflows (not just trivial cases)
  • Verify final state and context data

For CI/CD

  • Run tests on every commit and pull request
  • Require passing tests before merging
  • Monitor test execution time and optimize slow tests
  • Track code coverage and maintain high standards

Next Steps

See Also: