Guides
Testing Framework

ZoopFlow Testing Framework

Table of Contents

Introduction to Testing in ZoopFlow

Testing is a critical component of the ZoopFlow framework, ensuring reliable behavior across all components of the workflow system. ZoopFlow's layered architecture requires a comprehensive testing strategy that addresses each layer independently while also ensuring they work together properly.

This guide covers the testing infrastructure, approaches, and best practices for ZoopFlow. It includes details on testing individual steps, complete flows, signal handling, error handling, and integration with Temporal.io.

Testing Philosophy and Approach

ZoopFlow's testing philosophy is built around these key principles:

  1. Test-driven development (TDD): Write tests before implementing features to ensure proper test coverage and drive design decisions.

  2. Layer-specific testing: Each layer of the architecture should have dedicated tests:

    • Step-level tests for individual task units
    • Flow-level tests for workflow orchestration
    • Integration tests for system boundaries
    • End-to-end tests for full execution paths
  3. Isolation: Tests should be isolated from each other, with no shared state or dependencies between test cases.

  4. Determinism: Tests should produce the same results every time they are run, regardless of environment or timing.

  5. Mocking dependencies: External dependencies should be mocked to ensure tests focus on the unit under test.

  6. Comprehensive coverage: Tests should cover both happy paths and error paths, ensuring robust error handling.

Types of Tests

Unit Tests

Unit tests focus on testing individual components in isolation. In ZoopFlow, this primarily means testing:

  • Individual step implementations
  • Flow definitions
  • Signal handlers
  • Utility functions and helpers
  • Error handling mechanisms

Unit tests should be small, fast, and focused on a single piece of functionality.

Integration Tests

Integration tests verify that different parts of the system work correctly together. In ZoopFlow, integration tests typically focus on:

  • Flow execution with real step implementations
  • Signal handling across workflow boundaries
  • Temporal.io integration
  • Error propagation between components

End-to-End Tests

End-to-end tests verify the entire system works together, including external dependencies. These tests typically:

  • Execute complete flows with real dependencies
  • Verify proper execution and state management
  • Test error handling and recovery
  • Validate Temporal integration in realistic scenarios

Testing Tools and Setup

ZoopFlow uses Jest as its primary testing framework, along with custom utilities for workflow testing.

Jest Configuration

The Jest configuration is defined in jest.config.mjs and includes:

export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/__tests__/**',
    '!src/**/__mocks__/**',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

Test Environment Setup

The setup file (src/__tests__/setup.ts) initializes the test environment:

/**
 * Jest setup file
 * This file is executed before each test file
 */
 
// Global test setup
beforeAll(() => {
  // Global setup code that runs once before all tests
  jest.setTimeout(10000); // Set default timeout to 10 seconds
});
 
afterAll(() => {
  // Global teardown code that runs once after all tests
});
 
// Reset mocks before each test
beforeEach(() => {
  jest.resetAllMocks();
});

Mocking Temporal Dependencies

Temporal.io is a critical dependency that requires careful mocking for effective testing.

Workflow Mocks

ZoopFlow uses a dedicated mock for @temporalio/workflow in src/__mocks__/@temporalio/workflow.ts:

/**
 * Mock for @temporalio/workflow module
 */
 
// Mock sleep function
export const sleep = jest.fn().mockImplementation((timeMs: number) => {
  return new Promise(resolve => setTimeout(resolve, timeMs));
});
 
// Mock executeChild function (used for scoped execution)
export const executeChild = jest.fn().mockImplementation((fn: () => any) => {
  return fn();
});
 
// Mock setHandler function
export const setHandler = jest.fn();
 
// Mock info object
export const info = {
  workflowId: 'mock-workflow-id',
  runId: 'mock-run-id',
  workflowType: 'mock-workflow-type',
  taskQueue: 'mock-task-queue',
  continueAsNew: false,
  attempt: 1,
  startTime: new Date(),
  firstExecutionRunId: 'mock-first-execution-run-id',
};
 
// Mock memo object
export const memo = {
  retryableErrorTypes: ['RETRYABLE_ERROR'],
  errorRecoveryStrategy: 'retry-with-backoff',
};
 
// Provide a mockWorkflow object for tests
export const mockWorkflow = {
  sleep,
  executeChild,
  setHandler,
  info,
  memo,
};
 
export default mockWorkflow;

Testing with TestWorkflowEnvironment

For integration tests that need a more complete Temporal environment, use TestWorkflowEnvironment:

import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
 
describe('Temporal Integration Tests', () => {
  let testEnv: TestWorkflowEnvironment;
  let worker: Worker;
 
  beforeAll(async () => {
    // Set up the test environment
    testEnv = await TestWorkflowEnvironment.create();
    
    // Create the worker
    worker = await Worker.create({
      connection: testEnv.nativeConnection,
      taskQueue: 'test-queue',
      workflowsPath: require.resolve('../../core/temporal/workflows'),
      activities: {
        // Mock activities
        'test.activity': async (input) => {
          return { result: `Processed: ${JSON.stringify(input)}` };
        },
      },
    });
    
    // Start the worker
    await worker.start();
  });
 
  afterAll(async () => {
    // Shut down the worker and test environment
    await worker.shutdown();
    await testEnv.teardown();
  });
 
  it('should execute a workflow', async () => {
    // Start a workflow
    const handle = await testEnv.client.workflow.start('myWorkflow', {
      taskQueue: 'test-queue',
      args: [/* workflow arguments */],
    });
 
    // Wait for result
    const result = await handle.result();
    expect(result).toEqual(/* expected result */);
  });
});

Testing Steps in Isolation

Step Test Harness

The Step Test Harness provides a way to test individual steps in isolation:

// Example usage of the Step Test Harness
import { createStepTestHarness } from '../helpers';
import { reverseStringStep } from '../../core/examples/steps/string-transformer';
 
describe('reverseStringStep', () => {
  const harness = createStepTestHarness(reverseStringStep);
 
  beforeEach(() => {
    // Mock services if needed
    harness.mockServices({
      loggingService: {
        logEvent: jest.fn(),
      },
    });
  });
 
  it('should reverse the input string', async () => {
    const result = await harness.execute({ text: 'hello' });
 
    expect(result).toEqual({ 
      result: 'olleh', 
      metadata: expect.objectContaining({
        operation: 'reverse',
      }) 
    });
  });
 
  it('should throw error for invalid input', async () => {
    await expect(
      harness.execute({ invalidInput: 'test' } as any)
    ).rejects.toThrow();
  });
});

Mocking Step Context

For step tests, you can use the TestStepContext class to simulate the step execution context:

/**
 * Create a mock step context for testing
 */
export function createMockStepContext() {
  return new TestStepContext({
    stepId: 'test.step',
    flowId: 'test.flow',
    executionId: 'test-execution-id',
    attemptNumber: 1,
  });
}
 
// In your tests:
const context = createMockStepContext();
jest.spyOn(context, 'log');
jest.spyOn(context, 'setState');
 
const result = await myStep.execute(input, context);
 
expect(context.log).toHaveBeenCalledWith(
  'Step execution started',
  expect.any(Object)
);

Testing Step Input/Output

Test step validation by checking input and output schema validation:

it('should validate input correctly', () => {
  const step = numberTransformerStep;
  
  // Valid input
  expect(step.validateInput({ value: 42 })).toBe(true);
  
  // Invalid inputs
  expect(step.validateInput({ value: 'not-a-number' })).toBe(false);
  expect(step.validateInput({ invalidProp: 42 })).toBe(false);
});
 
it('should validate output correctly', () => {
  const step = numberTransformerStep;
  
  // Valid output
  const validOutput = {
    result: 84,
    metadata: {
      operation: 'multiply',
      factor: 2,
    },
  };
  expect(step.validateOutput(validOutput)).toBe(true);
  
  // Invalid output
  const invalidOutput = {
    result: 'not-a-number',
    metadata: {},
  };
  expect(step.validateOutput(invalidOutput)).toBe(false);
});

Testing Flows

Flow Test Harness

The Flow Test Harness allows testing complete flows while mocking individual steps:

import { createFlowTestHarness } from '../helpers';
import { textProcessingFlow } from '../../core/examples/flows/text-processing-flow';
import { upperCaseStep } from '../../core/examples/steps/string-transformer';
import { reverseStringStep } from '../../core/examples/steps/string-transformer';
 
describe('textProcessingFlow', () => {
  const harness = createFlowTestHarness(textProcessingFlow);
 
  beforeEach(() => {
    // Mock steps
    harness.mockStep(upperCaseStep, async (input) => ({
      result: input.text.toUpperCase(),
      metadata: { operation: 'uppercase' },
    }));
 
    harness.mockStep(reverseStringStep, async (input) => ({
      result: input.text.split('').reverse().join(''),
      metadata: { operation: 'reverse' },
    }));
  });
 
  it('should process text correctly', async () => {
    const result = await harness.execute({
      text: 'hello world',
    });
 
    expect(result).toEqual({
      result: 'DLROW OLLEH',
      metadata: {
        appliedOperations: ['uppercase', 'reverse'],
        timestamp: expect.any(String),
      },
    });
  });
});

Testing Flow Execution

Test the flow execution path and state management:

it('should execute flow steps in the correct order', async () => {
  const executionHistory = await harness.getExecutionHistory();
  
  // Verify step execution sequence
  expect(executionHistory).toContainEqual(
    expect.objectContaining({
      type: 'STEP_COMPLETED',
      stepId: 'upperCaseStep',
    })
  );
  
  expect(executionHistory).toContainEqual(
    expect.objectContaining({
      type: 'STEP_COMPLETED',
      stepId: 'reverseStringStep',
    })
  );
  
  // Verify execution order
  const upperCaseIndex = executionHistory.findIndex(
    event => event.type === 'STEP_COMPLETED' && event.stepId === 'upperCaseStep'
  );
  
  const reverseIndex = executionHistory.findIndex(
    event => event.type === 'STEP_COMPLETED' && event.stepId === 'reverseStringStep'
  );
  
  expect(upperCaseIndex).toBeLessThan(reverseIndex);
});

Testing Flow Validation

Test the flow definition validation logic:

import { defineFlow } from '../../core/flow/define-flow';
 
it('should validate required flow options', () => {
  // Missing ID
  expect(() => 
    defineFlow({
      version: '1.0.0',
      inputSchema: { type: 'object' },
      outputSchema: { type: 'object' },
      steps: async () => ({}),
    } as any)
  ).toThrow('Flow must have an id');
 
  // Invalid version format
  expect(() => 
    defineFlow({
      id: 'test.flow',
      version: '1.0', // Invalid format
      inputSchema: { type: 'object' },
      outputSchema: { type: 'object' },
      steps: async () => ({}),
    })
  ).toThrow('Flow version must be in format "x.y.z"');
});

Testing Signals

Signal Registration Tests

Test signal registration and validation:

import { defineSignal } from '../../core/flow/define-signal';
import { SignalRegistry } from '../../core/flow/signal-registry';
 
describe('Signal Registration', () => {
  let registry: SignalRegistry;
 
  beforeEach(() => {
    registry = new SignalRegistry();
  });
 
  it('should register a signal', () => {
    const signalDef = defineSignal<{ test: string }>({
      id: 'test.signal',
      payloadSchema: { type: 'object' },
      handler: async () => {},
    });
 
    registry.registerSignal(signalDef);
    expect(registry.hasSignal('test.signal')).toBe(true);
    expect(registry.getSignal('test.signal')).toBe(signalDef);
  });
 
  it('should throw an error if registering duplicate signal ID', () => {
    const signalDef1 = defineSignal({
      id: 'test.duplicate',
      payloadSchema: { type: 'object' },
      handler: async () => {},
    });
 
    const signalDef2 = defineSignal({
      id: 'test.duplicate',
      payloadSchema: { type: 'object' },
      handler: async () => {},
    });
 
    registry.registerSignal(signalDef1);
    expect(() => registry.registerSignal(signalDef2)).toThrow(/already registered/);
  });
});

Signal Handler Tests

Test signal handler execution:

import { defineSignal } from '../../core/flow/define-signal';
import { TestFlowContext } from '../helpers/test-context';
 
it('should execute handler function with payload and context', async () => {
  const mockContext = new TestFlowContext();
  const handlerFn = jest.fn().mockResolvedValue(undefined);
 
  const signalDef = defineSignal<{ value: string }>({
    id: 'test.signal',
    payloadSchema: {
      type: 'object',
      properties: {
        value: { type: 'string' },
      },
    },
    handler: handlerFn,
  });
 
  const payload = { value: 'test-value' };
  await signalDef.handler(payload, mockContext);
 
  expect(handlerFn).toHaveBeenCalledWith(payload, mockContext);
});

Signal Integration Tests

Test signals with a complete workflow:

import { WorkflowClient } from '@temporalio/client';
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
import { TemporalBridge } from '../../core/temporal/temporal-bridge';
import { createFlowControlSignals } from '../../core/temporal/signalHandlers';
 
describe('Signal Integration Tests', () => {
  let testEnv: TestWorkflowEnvironment;
  let worker: Worker;
  let client: WorkflowClient;
  let temporalBridge: TemporalBridge;
  let handle;
 
  beforeAll(async () => {
    // Setup test environment
    testEnv = await TestWorkflowEnvironment.create();
    client = testEnv.client;
    temporalBridge = new TemporalBridge({
      connection: testEnv.nativeConnection,
      taskQueue: 'test-queue',
    });
 
    // Register signals
    const signals = createFlowControlSignals();
    signals.forEach(signal => {
      temporalBridge.registerSignalForFlow('test.flow', signal);
    });
 
    // Create worker
    worker = await Worker.create({
      connection: testEnv.nativeConnection,
      taskQueue: 'test-queue',
      workflowsPath: require.resolve('../../core/temporal/workflows'),
      activities: {
        'test.activity': async (input) => ({ result: input }),
      },
    });
 
    await worker.start();
  });
 
  beforeEach(async () => {
    // Start workflow
    handle = await client.start('executeFlow', {
      taskQueue: 'test-queue',
      args: ['test.flow', { input: 'test' }],
    });
    await new Promise(resolve => setTimeout(resolve, 100));
  });
 
  it('should pause a workflow when pause signal is sent', async () => {
    // Send pause signal
    await handle.signal('flow.pause', { reason: 'Test pause' });
    await new Promise(resolve => setTimeout(resolve, 100));
 
    // Get workflow status
    const state = await handle.query('getState');
    expect(state.status).toBe('PAUSED');
    expect(state.pauseReason).toBe('Test pause');
  });
 
  afterAll(async () => {
    await worker.shutdown();
    await testEnv.teardown();
  });
});

Testing Error Handling

Error Classes

Test custom error classes and their behavior:

import {
  FlowError,
  FlowValidationError,
  FlowExecutionError,
  FlowErrorUtils,
} from '../../core/error/flow-errors';
 
describe('Flow Error Classes', () => {
  it('should create a base flow error with default values', () => {
    const error = new FlowError('Test error', 'TEST_ERROR');
 
    expect(error.message).toBe('Test error');
    expect(error.errorCode).toBe('TEST_ERROR');
    expect(error.category).toBe(ErrorCategory.OTHER);
    expect(error.severity).toBe(ErrorSeverity.ERROR);
    expect(error.recoverable).toBe(false);
  });
 
  it('should convert unknown error to FlowError', () => {
    const originalError = new Error('Original error');
    const flowError = FlowErrorUtils.toFlowError(originalError);
 
    expect(flowError).toBeInstanceOf(FlowExecutionError);
    expect(flowError.message).toBe('Original error');
    expect(flowError.cause).toBe(originalError);
  });
});

Error Propagation

Test error propagation through the flow:

import { FlowInterpreter } from '../../core/interpreter/flow-interpreter';
import { NodeHandlerRegistry } from '../../core/interpreter/node-handler-registry';
 
it('should propagate errors from steps to the flow', async () => {
  const errorStep = {
    id: 'error-step',
    execute: jest.fn().mockRejectedValue(new Error('Step execution failed')),
  };
 
  const registry = new StepRegistry();
  registry.registerStep(errorStep);
 
  const flowDef = {
    // Flow definition with errorStep
  };
 
  const interpreter = new FlowInterpreter(flowDef, registry);
  
  await expect(interpreter.execute({ input: 'test' }))
    .rejects.toThrow('Step execution failed');
});

Error Recovery

Test error recovery mechanisms:

import { FlowErrorHandler } from '../../core/error/error-handler-registry';
 
it('should recover from retryable errors', async () => {
  const mockStep = {
    id: 'retryable-step',
    execute: jest.fn()
      .mockRejectedValueOnce(new FlowDependencyError('Database error', 'db'))
      .mockResolvedValueOnce({ success: true }),
  };
 
  const errorHandler = new FlowErrorHandler({
    retryStrategy: 'immediate',
    maxRetries: 1,
  });
 
  const result = await errorHandler.handleStepError(
    mockStep.execute, [], mockContext, mockStep
  );
 
  expect(mockStep.execute).toHaveBeenCalledTimes(2);
  expect(result).toEqual({ success: true });
});

Test Contexts and Utilities

TestStepContext

The TestStepContext class provides a mock implementation of the step context:

/**
 * Simple test context implementation
 */
export class TestStepContext implements StepContextType {
  public logCalls: Array<{ message: string; metadata?: Record<string, unknown> }> = [];
  public stateStore: Map<string, any> = new Map();
  public serviceStore: Map<string, any> = new Map();
  public info: ExecutionInfo;
  public stepMetadata: StepMetadata;
 
  constructor(executionInfo?: Partial<ExecutionInfo>, metadata?: StepMetadata) {
    this.info = {
      stepId: executionInfo?.stepId || 'test.step',
      flowId: executionInfo?.flowId || 'test.flow',
      executionId: executionInfo?.executionId || 'test-execution-id',
      attemptNumber: executionInfo?.attemptNumber || 1,
    };
 
    this.stepMetadata = metadata || {
      id: 'test.step',
      version: '1.0.0',
      displayName: 'Test Step',
      description: 'A step for testing',
      inputSchema: { type: 'object' },
      outputSchema: { type: 'object' },
    };
  }
 
  log(message: string, metadata?: Record<string, unknown>): void {
    this.logCalls.push({ message, metadata });
  }
 
  async getState<T>(key: string): Promise<T | undefined> {
    return this.stateStore.get(key) as T | undefined;
  }
 
  async setState<T>(key: string, value: T): Promise<void> {
    this.stateStore.set(key, value);
  }
 
  getService<T>(name: string, _options?: Record<string, unknown>): T {
    return this.serviceStore.get(name) as T;
  }
 
  /**
   * Register a service for testing
   */
  registerService<T>(name: string, service: T): void {
    this.serviceStore.set(name, service);
  }
}

Test Utilities

Test utilities help with common testing tasks:

/**
 * Test utilities for common test operations
 */
 
/**
 * Validates that a version string follows semantic versioning (x.y.z)
 * @param version The version string to validate
 * @returns True if the version is valid, false otherwise
 */
export function isValidVersion(version: string): boolean {
  const semverRegex = /^(\d+)\.(\d+)\.(\d+)$/;
  return semverRegex.test(version);
}
 
/**
 * Validates that an ID follows the namespace.name format
 * @param id The ID to validate
 * @returns True if the ID is valid, false otherwise
 */
export function isValidId(id: string): boolean {
  const idRegex = /^[a-z0-9-]+\.[a-z0-9-]+$/i;
  return idRegex.test(id);
}
 
/**
 * Wait for a specified amount of time
 * @param ms Milliseconds to wait
 * @returns A promise that resolves after the specified time
 */
export function wait(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Test Assertions and Matchers

Custom Assertions

ZoopFlow provides custom assertions for common testing scenarios:

/**
 * Assert that a value matches a JSON Schema
 * @param value The value to validate
 * @param schema The JSON Schema to validate against
 */
export function assertMatchesSchema(value: any, schema: Record<string, any>): void {
  if (schema.type === 'object') {
    expect(typeof value).toBe('object');
    expect(value).not.toBeNull();
 
    // Check required properties
    if (schema.required) {
      schema.required.forEach((prop: string) => {
        expect(value).toHaveProperty(prop);
      });
    }
 
    // Check property types
    if (schema.properties) {
      Object.entries(schema.properties).forEach(([prop, propSchema]: [string, any]) => {
        if (value[prop] !== undefined) {
          if (propSchema.type === 'string') {
            expect(typeof value[prop]).toBe('string');
          } else if (propSchema.type === 'number') {
            expect(typeof value[prop]).toBe('number');
          } else if (propSchema.type === 'boolean') {
            expect(typeof value[prop]).toBe('boolean');
          } else if (propSchema.type === 'object') {
            expect(typeof value[prop]).toBe('object');
            expect(value[prop]).not.toBeNull();
          } else if (propSchema.type === 'array') {
            expect(Array.isArray(value[prop])).toBe(true);
          }
        }
      });
    }
  }
  // Additional type checks...
}
 
/**
 * Assert that a function throws an error matching a pattern
 * @param fn The function to execute
 * @param errorPattern The error message pattern to match
 */
export async function assertThrowsAsync(
  fn: () => Promise<any>,
  errorPattern?: RegExp | string,
): Promise<void> {
  let error: Error | null = null;
  try {
    await fn();
  } catch (e) {
    error = e as Error;
  }
 
  expect(error).not.toBeNull();
 
  if (errorPattern) {
    if (errorPattern instanceof RegExp) {
      expect(error!.message).toMatch(errorPattern);
    } else {
      expect(error!.message).toContain(errorPattern);
    }
  }
}

Schema Validation

Test schema validation for steps and flows:

import { SchemaValidator } from '../../core/utils/schema-validator';
 
describe('Schema Validation', () => {
  const validator = new SchemaValidator();
  
  const schema = {
    type: 'object',
    properties: {
      name: { type: 'string' },
      age: { type: 'number' },
    },
    required: ['name'],
  };
  
  it('should validate valid data', () => {
    const result = validator.validate({ name: 'John', age: 30 }, schema);
    expect(result.valid).toBe(true);
    expect(result.errors).toBeUndefined();
  });
  
  it('should reject invalid data', () => {
    const result = validator.validate({ age: 'thirty' }, schema);
    expect(result.valid).toBe(false);
    expect(result.errors).toBeDefined();
    
    if (result.errors) {
      expect(result.errors).toContainEqual(
        expect.objectContaining({
          path: expect.stringContaining('name'),
          message: expect.stringContaining('required'),
        })
      );
      
      expect(result.errors).toContainEqual(
        expect.objectContaining({
          path: expect.stringContaining('age'),
          message: expect.stringContaining('number'),
        })
      );
    }
  });
});

Performance Testing

Measuring Execution Time

Measure step and flow execution times:

it('should execute within performance threshold', async () => {
  const startTime = Date.now();
  
  await myStep.execute(input, context);
  
  const duration = Date.now() - startTime;
  expect(duration).toBeLessThan(100); // 100ms threshold
});

Load Testing

Test performance under load:

it('should handle concurrent executions', async () => {
  const concurrentExecutions = 10;
  const startTime = Date.now();
  
  // Execute multiple flows concurrently
  const promises = Array(concurrentExecutions).fill(0).map((_, i) => 
    flow.execute({ id: `test-${i}` })
  );
  
  const results = await Promise.all(promises);
  
  const duration = Date.now() - startTime;
  const avgDuration = duration / concurrentExecutions;
  
  expect(results).toHaveLength(concurrentExecutions);
  expect(avgDuration).toBeLessThan(500); // 500ms average threshold
});

Test Organization and Best Practices

File Structure

Follow this file structure for tests:

src/
├── __tests__/
│   ├── core/
│   │   ├── flow/
│   │   │   ├── define-flow.test.ts
│   │   │   ├── signal-handling.test.ts
│   │   ├── steps/
│   │   │   ├── string-transformer.test.ts
│   │   │   ├── number-transformer.test.ts
│   │   ├── error/
│   │   │   ├── flow-errors.test.ts
│   ├── integration/
│   │   ├── flowTemporal.test.ts
│   ├── helpers/
│   │   ├── assertions.ts
│   │   ├── test-context.ts
│   │   ├── test-utils.ts
│   │   ├── index.ts
│   ├── setup.ts

Naming Conventions

Follow these naming conventions:

  1. Test files should be named *.test.ts to match Jest's test patterns
  2. Use descriptive test names that indicate what's being tested
  3. Use lowercase for directory names and kebab-case for test files
  4. Use descriptive describe and it blocks that form readable sentences

Example:

describe('StringTransformer', () => {
  describe('reverseString', () => {
    it('should reverse the input string', () => {
      // Test code
    });
    
    it('should return empty string when input is empty', () => {
      // Test code
    });
  });
});

Test Coverage

Maintain high test coverage:

  1. Aim for at least 80% code coverage across branches, functions, and lines
  2. Focus on testing complex logic and error paths
  3. Run coverage reports regularly with npm run test:coverage
  4. Add tests for all new features and bug fixes

Continuous Integration

CI Pipeline Configuration

Configure CI for automated testing:

# Example CI configuration
name: Test
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '16.x'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Lint
        run: npm run lint
      
      - name: Test
        run: npm run test
      
      - name: Coverage
        run: npm run test:coverage
      
      - name: Upload coverage reports
        uses: codecov/codecov-action@v1

Automated Testing

Implement automated testing best practices:

  1. Run tests on every PR and push to main branches
  2. Require passing tests before merging
  3. Generate and publish test coverage reports
  4. Monitor test execution time and performance
  5. Automatically flag flaky tests for investigation

Examples of Different Test Types

Step Test Example

// Example step test
import { createStepTestHarness } from '../helpers';
import { upperCaseStep } from '../../core/examples/steps/string-transformer';
 
describe('upperCaseStep', () => {
  const harness = createStepTestHarness(upperCaseStep);
 
  it('should convert string to uppercase', async () => {
    const result = await harness.execute({ text: 'hello world' });
 
    expect(result).toEqual({
      result: 'HELLO WORLD',
      metadata: {
        operation: 'uppercase',
        timestamp: expect.any(String),
      },
    });
  });
 
  it('should throw an error if input is missing text property', async () => {
    await expect(
      harness.execute({} as any)
    ).rejects.toThrow('required property');
  });
 
  it('should throw an error if text is not a string', async () => {
    await expect(
      harness.execute({ text: 123 } as any)
    ).rejects.toThrow('must be string');
  });
});

Flow Test Example

// Example flow test
import { createFlowTestHarness } from '../helpers';
import { textProcessingFlow } from '../../core/examples/flows/text-processing-flow';
 
describe('textProcessingFlow', () => {
  const harness = createFlowTestHarness(textProcessingFlow);
 
  it('should process text correctly with default options', async () => {
    const input = { text: 'hello world' };
    const result = await harness.execute(input);
 
    // Verify the result
    expect(result).toEqual({
      result: 'DLROW OLLEH',
      metadata: {
        appliedOperations: ['uppercase', 'reverse'],
        timestamp: expect.any(String),
      },
    });
 
    // Verify step execution
    const history = await harness.getExecutionHistory();
    expect(history).toContainEqual(
      expect.objectContaining({
        type: 'STEP_COMPLETED',
        stepId: 'string-transformer.uppercase',
      })
    );
 
    expect(history).toContainEqual(
      expect.objectContaining({
        type: 'STEP_COMPLETED',
        stepId: 'string-transformer.reverse',
      })
    );
  });
 
  it('should include original text when requested', async () => {
    const input = {
      text: 'hello world',
      options: { includeOriginal: true },
    };
 
    const result = await harness.execute(input);
 
    // Verify the result includes original text
    expect(result.metadata.originalText).toBe('hello world');
  });
});

Signal Test Example

// Example signal test
import { defineSignal } from '../../core/flow/define-signal';
import { TestFlowContext } from '../helpers/test-context';
import { FlowExecutionStatus } from '../../core/interfaces/types';
 
describe('Flow Pause Signal', () => {
  it('should update flow status to PAUSED', async () => {
    // Create test context
    const context = new TestFlowContext();
    
    // Define pause signal
    const pauseSignal = defineSignal<{ reason?: string }>({
      id: 'flow.pause',
      description: 'Signal to pause workflow execution',
      payloadSchema: {
        type: 'object',
        properties: {
          reason: { type: 'string' },
        },
      },
      handler: async (payload, ctx) => {
        await ctx.setState('status', FlowExecutionStatus.PAUSED);
        if (payload.reason) {
          await ctx.setState('pauseReason', payload.reason);
        }
        await ctx.setState('pausedAt', new Date().toISOString());
      },
    });
    
    // Execute signal handler
    await pauseSignal.handler({ reason: 'Manual pause' }, context);
    
    // Verify state changes
    const status = await context.getState('status');
    const reason = await context.getState('pauseReason');
    const pausedAt = await context.getState('pausedAt');
    
    expect(status).toBe(FlowExecutionStatus.PAUSED);
    expect(reason).toBe('Manual pause');
    expect(pausedAt).toBeDefined();
  });
});

Integration Test Example

// Example integration test with Temporal
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
import { TemporalBridge } from '../../core/temporal/temporal-bridge';
import { textProcessingFlow } from '../../core/examples/flows/text-processing-flow';
 
describe('Flow Temporal Integration', () => {
  let testEnv;
  let worker;
 
  beforeAll(async () => {
    // Setup
    testEnv = await TestWorkflowEnvironment.create();
    
    // Create Temporal bridge
    const temporalBridge = new TemporalBridge({
      connection: testEnv.nativeConnection,
      taskQueue: 'test-queue',
    });
    
    // Register flow
    temporalBridge.registerFlow(textProcessingFlow);
    
    // Create worker
    worker = await Worker.create({
      connection: testEnv.nativeConnection,
      taskQueue: 'test-queue',
      workflowsPath: require.resolve('../../core/temporal/flow-workflow'),
      activities: {
        'string-transformer.uppercase': async (input: { text: string }) => {
          return {
            result: input.text.toUpperCase(),
            metadata: { operation: 'uppercase' },
          };
        },
        'string-transformer.reverse': async (input: { text: string }) => {
          return {
            result: input.text.split('').reverse().join(''),
            metadata: { operation: 'reverse' },
          };
        },
      },
    });
    
    await worker.start();
  });
  
  afterAll(async () => {
    await worker.shutdown();
    await testEnv.teardown();
  });
  
  it('should execute flow through Temporal', async () => {
    // Start workflow
    const handle = await testEnv.client.workflow.start('executeFlow', {
      taskQueue: 'test-queue',
      args: ['zoop.text-processing', { text: 'hello world' }],
    });
    
    // Wait for result
    const result = await handle.result();
    
    // Verify result
    expect(result).toEqual({
      result: 'DLROW OLLEH',
      metadata: {
        appliedOperations: ['uppercase', 'reverse'],
        timestamp: expect.any(String),
      },
    });
  });
});