DEV Community

Cover image for Testing AWS Lambda Durable Functions in TypeScript
Eric D Johnson for AWS

Posted on

Testing AWS Lambda Durable Functions in TypeScript

How to write fast, reliable tests for workflows that span hours

Testing long-running workflows is tricky. Your function waits for callbacks, retries failed operations, and spans multiple invocations. How do you test that without actually waiting hours or deploying to AWS?

The AWS Durable Execution SDK includes a testing library that solves this. You can run your durable functions locally, skip time-based waits, inspect every operation, and verify behavior without touching AWS. Let's see how.

The Testing Library

The SDK provides testing tools for different scenarios:

LocalDurableTestRunner runs your function in-process with a simulated checkpoint server. Tests execute in milliseconds, even for workflows that would normally take hours. This is what you'll use for most testing.

CloudDurableTestRunner tests against deployed Lambda functions in AWS. Use this for integration tests or when you need to verify behavior in the actual AWS environment.

run-durable CLI provides quick command-line testing without writing test code. Perfect for rapid iteration and debugging during development.

We'll focus on local testing since that's where you'll spend most of your time.

Your First Test

Let's start with a simple durable function:

// order-processor.ts
import { DurableContext, withDurableExecution } from '@aws/durable-execution-sdk-js';

export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const order = await context.step('create-order', async () => {
      return { orderId: '123', total: 50 };
    });

    await context.wait({ seconds: 300 }); // Wait 5 minutes

    const notification = await context.step('send-notification', async () => {
      return { sent: true };
    });

    return { order, notification };
  }
);
Enter fullscreen mode Exit fullscreen mode

Here's how to test it:

// order-processor.test.ts
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { handler } from './order-processor';

describe('Order Processor', () => {
  beforeAll(async () => {
    await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
  });

  afterAll(async () => {
    await LocalDurableTestRunner.teardownTestEnvironment();
  });

  it('should process order successfully', async () => {
    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = await runner.run();

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({
      order: { orderId: '123', total: 50 },
      notification: { sent: true }
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

That's it. The test runs in milliseconds, even though the function has a 5-minute wait. The skipTime: true option tells the test runner to skip over time-based operations instantly.

Setup and Teardown

Every test suite needs setup and teardown:

beforeAll(async () => {
  await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
});

afterAll(async () => {
  await LocalDurableTestRunner.teardownTestEnvironment();
});
Enter fullscreen mode Exit fullscreen mode

setupTestEnvironment() starts a local checkpoint server and optionally installs fake timers for time skipping. teardownTestEnvironment() cleans everything up. Call these once per test file in beforeAll and afterAll.

The skipTime option is crucial for fast tests. When enabled, operations like context.wait(), setTimeout, and retry delays complete instantly. Without it, your tests would actually wait for the specified durations.

Inspecting Operations

The real power of the testing library is inspecting what your function did:

it('should execute operations in correct order', async () => {
  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const execution = await runner.run();

  // Get all operations
  const operations = execution.getOperations();
  expect(operations).toHaveLength(3); // create-order, wait, send-notification

  // Get specific operation by index
  const createOrder = runner.getOperationByIndex(0);
  expect(createOrder.getType()).toBe('STEP');
  expect(createOrder.getStatus()).toBe('SUCCEEDED');
  expect(createOrder.getStepDetails()?.result).toEqual({
    orderId: '123',
    total: 50
  });

  // Get wait operation
  const waitOp = runner.getOperationByIndex(1);
  expect(waitOp.getType()).toBe('WAIT');
  expect(waitOp.getWaitDetails()?.waitSeconds).toBe(300);

  // Get notification operation
  const notification = runner.getOperationByIndex(2);
  expect(notification.getStepDetails()?.result).toEqual({ sent: true });
});
Enter fullscreen mode Exit fullscreen mode

You can access operations by index, by name, or by ID. Each operation exposes its type, status, and type-specific details like step results or wait durations.

Testing Failures and Retries

Real workflows fail. Let's test retry behavior:

// payment-processor.ts
import { DurableContext, withDurableExecution, createRetryStrategy } from '@aws/durable-execution-sdk-js';

export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const payment = await context.step('process-payment', async () => {
      const response = await fetch('https://api.payments.com/charge', {
        method: 'POST',
        body: JSON.stringify({ amount: event.amount })
      });
      if (!response.ok) throw new Error('Payment failed');
      return response.json();
    }, {
      retryStrategy: createRetryStrategy({
        maxAttempts: 3,
        backoffRate: 2,
        initialInterval: 1000
      })
    });

    return payment;
  }
);
Enter fullscreen mode Exit fullscreen mode

Test it with mocks:

// payment-processor.test.ts
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { handler } from './payment-processor';

// Store original fetch
const originalFetch = global.fetch;

describe('Payment Processor', () => {
  beforeAll(async () => {
    await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
  });

  afterAll(async () => {
    await LocalDurableTestRunner.teardownTestEnvironment();
  });

  beforeEach(() => {
    // Mock fetch for external APIs only
    global.fetch = jest.fn((url: string | URL | Request, ...args) => {
      const urlString = url.toString();
      // Let checkpoint server calls through
      if (urlString.includes('127.0.0.1') || urlString.includes('localhost')) {
        return originalFetch(url as any, ...args);
      }
      // Mock external API calls
      return Promise.reject(new Error('Unmocked fetch call'));
    }) as any;
  });

  afterEach(() => {
    global.fetch = originalFetch;
  });

  it('should succeed on first attempt', async () => {
    (global.fetch as jest.Mock).mockImplementation((url: string | URL | Request, ...args) => {
      const urlString = url.toString();
      if (urlString.includes('127.0.0.1') || urlString.includes('localhost')) {
        return originalFetch(url as any, ...args);
      }
      if (urlString.includes('api.payments.com')) {
        return Promise.resolve({
          ok: true,
          json: async () => ({ transactionId: 'txn-123', status: 'success' })
        });
      }
      return Promise.reject(new Error('Unmocked fetch'));
    });

    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = await runner.run({ payload: { amount: 100 } });

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({
      transactionId: 'txn-123',
      status: 'success'
    });
  });

  it('should retry on failure and eventually succeed', async () => {
    let callCount = 0;
    (global.fetch as jest.Mock).mockImplementation((url: string | URL | Request, ...args) => {
      const urlString = url.toString();
      if (urlString.includes('127.0.0.1') || urlString.includes('localhost')) {
        return originalFetch(url as any, ...args);
      }
      if (urlString.includes('api.payments.com')) {
        callCount++;
        if (callCount === 1) {
          return Promise.reject(new Error('Network error'));
        }
        return Promise.resolve({
          ok: true,
          json: async () => ({ transactionId: 'txn-456', status: 'success' })
        });
      }
      return Promise.reject(new Error('Unmocked fetch'));
    });

    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = await runner.run({ payload: { amount: 100 } });

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({
      transactionId: 'txn-456',
      status: 'success'
    });
  });

  it('should fail after exhausting retries', async () => {
    (global.fetch as jest.Mock).mockImplementation((url: string | URL | Request, ...args) => {
      const urlString = url.toString();
      if (urlString.includes('127.0.0.1') || urlString.includes('localhost')) {
        return originalFetch(url as any, ...args);
      }
      if (urlString.includes('api.payments.com')) {
        return Promise.reject(new Error('Persistent failure'));
      }
      return Promise.reject(new Error('Unmocked fetch'));
    });

    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = await runner.run({ payload: { amount: 100 } });

    expect(execution.getStatus()).toBe('FAILED');
    const error = execution.getError();
    expect(error?.errorMessage).toBe('Persistent failure');
    expect(error?.errorType).toBe('StepError');
  });
});
Enter fullscreen mode Exit fullscreen mode

With skipTime: true, the retry delays happen instantly. The test verifies retry behavior without actually waiting for backoff intervals.

Important: When mocking fetch, preserve calls to localhost/127.0.0.1 so the checkpoint server can communicate. Only mock external API calls.

Testing Callbacks

Durable functions often wait for external events. Here's how to test that:

// approval-workflow.ts
import { DurableContext, withDurableExecution } from '@aws/durable-execution-sdk-js';

export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const request = await context.step('create-request', async () => {
      return { requestId: event.requestId, status: 'pending' };
    });

    // Wait up to 24 hours for approval
    try {
      const approval = await context.waitForCallback('approval', 86400);
      return { status: 'approved', approvedBy: approval.userId };
    } catch (error) {
      return { status: 'timeout' };
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Test with callback simulation:

// approval-workflow.test.ts
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { handler } from './approval-workflow';

describe('Approval Workflow', () => {
  beforeAll(async () => {
    await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
  });

  afterAll(async () => {
    await LocalDurableTestRunner.teardownTestEnvironment();
  });

  it('should complete when approval is received', async () => {
    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = runner.run({ payload: { requestId: '123' } });

    // Get the callback operation
    const callbackOp = runner.getOperation('approval');

    // Simulate approval callback
    await callbackOp.sendCallbackSuccess({ userId: 'user-456' });

    const result = await execution;

    expect(result.getStatus()).toBe('SUCCEEDED');
    expect(result.getResult()).toEqual({
      status: 'approved',
      approvedBy: 'user-456'
    });
  });

  it('should timeout when no approval is received', async () => {
    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    // Don't send callback - let it timeout
    const execution = await runner.run({ payload: { requestId: '123' } });

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({ status: 'timeout' });
  });
});
Enter fullscreen mode Exit fullscreen mode

The getOperation() method returns an operation handle that lets you send callbacks using sendCallbackSuccess(). With skipTime: true, the timeout happens instantly if no callback is sent.

Testing Parallel Operations

Durable functions can run operations in parallel:

// batch-processor.ts
import { DurableContext, withDurableExecution } from '@aws/durable-execution-sdk-js';

export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    await context.parallel(
      'process-items',
      event.items.map((item: any, index: number) =>
        async (childContext: DurableContext) => {
          return await childContext.step(`process-item-${index}`, async () => {
            return { id: item.id, processed: true };
          });
        }
      )
    );

    return {
      total: event.items.length,
      successful: event.items.length
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

Test parallel execution:

// batch-processor.test.ts
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { handler } from './batch-processor';

describe('Batch Processor', () => {
  beforeAll(async () => {
    await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
  });

  afterAll(async () => {
    await LocalDurableTestRunner.teardownTestEnvironment();
  });

  it('should process all items in parallel', async () => {
    const runner = new LocalDurableTestRunner({
      handlerFunction: handler,
    });

    const execution = await runner.run({
      payload: {
        items: [
          { id: '1' },
          { id: '2' },
          { id: '3' }
        ]
      }
    });

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({
      total: 3,
      successful: 3
    });

    // Verify operations were tracked
    const operations = execution.getOperations();
    expect(operations.length).toBeGreaterThan(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

The context.parallel() method takes a name and an array of functions that each receive a child context. Each parallel operation is tracked independently.

Testing Nested Functions

Durable functions can invoke other durable functions:

// main-workflow.ts
import { DurableContext, withDurableExecution } from '@aws/durable-execution-sdk-js';

export const childHandler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const result = await context.step('child-step', async () => {
      return { processed: event.data };
    });
    return result;
  }
);

export const mainHandler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const childResult = await context.invoke('child-function', {
      data: event.input
    });

    return { main: 'completed', child: childResult };
  }
);
Enter fullscreen mode Exit fullscreen mode

Register child functions for testing:

// main-workflow.test.ts
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { mainHandler, childHandler } from './main-workflow';

describe('Main Workflow', () => {
  beforeAll(async () => {
    await LocalDurableTestRunner.setupTestEnvironment({ skipTime: true });
  });

  afterAll(async () => {
    await LocalDurableTestRunner.teardownTestEnvironment();
  });

  it('should invoke child function successfully', async () => {
    const runner = new LocalDurableTestRunner({
      handlerFunction: mainHandler,
    });

    // Register the child function
    runner.registerDurableFunction('child-function', childHandler);

    const execution = await runner.run({ payload: { input: 'test-data' } });

    expect(execution.getStatus()).toBe('SUCCEEDED');
    expect(execution.getResult()).toEqual({
      main: 'completed',
      child: { processed: 'test-data' }
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The registerDurableFunction() method tells the test runner how to handle context.invoke() calls. Without registration, invocations would fail.

Quick Testing with the CLI

For rapid iteration without writing test code, use the run-durable CLI:

npm run run-durable -- path/to/your-function.ts
Enter fullscreen mode Exit fullscreen mode

The CLI runs your function locally and displays an operations table automatically. Useful options:

  • no-skip-time - Actually wait for delays (default skips time)
  • verbose - Show detailed execution logs
  • show-history - Display history events table
# Run with verbose logging and history
npm run run-durable -- src/order-processor.ts verbose show-history
Enter fullscreen mode Exit fullscreen mode

The CLI is perfect for quick debugging and verifying function behavior without setting up a full test suite.

Debugging with Execution History

When tests fail, the execution history helps you understand what happened:

it('should provide detailed execution history', async () => {
  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const execution = await runner.run();

  // Print operations table to console
  execution.print();

  // Get detailed history events
  const history = execution.getHistoryEvents();
  console.log('History:', JSON.stringify(history, null, 2));

  // Get all invocations
  const invocations = execution.getInvocations();
  console.log('Invocations:', invocations.length);

  // Filter operations by status
  const succeeded = execution.getOperations({ status: 'SUCCEEDED' });
  const failed = execution.getOperations({ status: 'FAILED' });
});
Enter fullscreen mode Exit fullscreen mode

The print() method outputs a formatted table of all operations. The history events show every checkpoint, invocation, and state transition. You can filter operations by status to focus on specific outcomes.

Additional operation access methods:

  • getOperationByNameAndIndex(name, index) - Get specific occurrence of a named operation
  • getOperationById(id) - Get operation by its unique ID

Testing Without Time Skipping

Sometimes you want to test actual timing behavior:

it('should respect actual wait durations', async () => {
  // Setup without skipTime
  await LocalDurableTestRunner.setupTestEnvironment({ skipTime: false });

  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const startTime = Date.now();
  await runner.run();
  const duration = Date.now() - startTime;

  // Should have actually waited ~2 seconds
  expect(duration).toBeGreaterThanOrEqual(2000);

  await LocalDurableTestRunner.teardownTestEnvironment();
});
Enter fullscreen mode Exit fullscreen mode

Without skipTime, waits and retries happen in real time. Use this sparingly - most tests should skip time for speed.

Cloud Testing

For integration tests against deployed functions:

import { CloudDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';

describe('Deployed Function Integration Tests', () => {
  it('should execute in AWS', async () => {
    const runner = new CloudDurableTestRunner({
      functionName: 'OrderProcessorFunction:$LATEST',
      region: 'us-east-1',
    });

    const execution = await runner.run({ payload: {} });

    expect(execution.getStatus()).toBe('SUCCEEDED');
  }, 600000); // 10 minute timeout for cloud tests
});
Enter fullscreen mode Exit fullscreen mode

Cloud testing invokes your actual Lambda function and waits for completion. Important notes:

  • Qualifier required: You must specify a qualifier (:$LATEST, :prod, etc.) when invoking durable functions
  • Execution timeout constraint: For synchronous invocation, the function's ExecutionTimeout in DurableConfig must not exceed 900 seconds (15 minutes)
  • Test timeout: Set a Jest timeout that accounts for your workflow duration - cloud tests run in real time
  • Real waits: Unlike local tests, cloud tests actually wait for the specified durations (e.g., 5 minutes for a 300-second wait)
  • AWS credentials: Ensure AWS credentials are configured. You can set the AWS_PROFILE environment variable or use other AWS credential providers

Cloud testing is slower than local testing but verifies behavior in the real AWS environment with actual checkpointing and state management.

Best Practices

Use local testing for most tests. It's fast, doesn't require AWS credentials, and gives you full control over timing and callbacks.

Skip time by default. Set skipTime: true in setupTestEnvironment() unless you specifically need to test timing behavior.

Mock external dependencies. Use Jest mocks for API calls, database operations, and other external services. This keeps tests fast and deterministic.

Test failure scenarios. Don't just test the happy path. Verify retry behavior, timeout handling, and error propagation.

Inspect operations, not just results. Use getOperation() and getOperations() to verify that your function executed the right steps in the right order.

Register nested functions. If your function uses context.invoke(), register the child functions with registerDurableFunction().

Use cloud testing sparingly. Reserve CloudDurableTestRunner for integration tests that need to verify behavior in AWS.

Common Patterns

Testing with dynamic operation names:

it('should handle dynamic operation names', async () => {
  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const execution = await runner.run({ payload: { userId: '123' } });

  // Get operation by name pattern
  const userOp = runner.getOperation('process-user-123');
  expect(userOp.getStatus()).toBe('SUCCEEDED');
});
Enter fullscreen mode Exit fullscreen mode

Testing error propagation:

it('should propagate errors correctly', async () => {
  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const execution = await runner.run();

  expect(execution.getStatus()).toBe('FAILED');
  const error = execution.getError();
  expect(error?.errorType).toBe('StepError');
  expect(error?.errorMessage).toContain('expected error message');
});
Enter fullscreen mode Exit fullscreen mode

Testing with multiple callbacks:

it('should handle multiple callbacks', async () => {
  const runner = new LocalDurableTestRunner({
    handlerFunction: handler,
  });

  const execution = runner.run();

  const callback1 = runner.getOperation('callback-1');
  const callback2 = runner.getOperation('callback-2');

  await callback1.sendCallback({ data: 'first' });
  await callback2.sendCallback({ data: 'second' });

  const result = await execution;
  expect(result.getStatus()).toBe('SUCCEEDED');
});
Enter fullscreen mode Exit fullscreen mode

Summary

The Durable Execution testing library makes it easy to test long-running workflows. Run functions locally with simulated checkpoints, skip time-based operations for fast tests, inspect every operation, and verify behavior without deploying to AWS.

Use LocalDurableTestRunner for fast unit tests with full control over timing and callbacks. Use CloudDurableTestRunner for integration tests against deployed functions. Mock external dependencies, test failure scenarios, and inspect operations to verify correct behavior.

With these tools, testing durable functions is as straightforward as testing any other code. Write tests first, iterate quickly, and ship workflows with confidence.

Top comments (0)