Arrange Act Assert

Jag Reehal's thoughts on things, mostly product development

Test Lambda durable functions without deploying

16 Nov 2026

AWS’s durable execution test runner runs a Lambda durable function in your test process, skips its waits and lets you answer its callbacks. We used it to test every branch of a payment approval in under a second.

A laptop with a terminal open inside a walled-off box that holds a Lambda function, message queues, a storage bucket, databases and containers, with the cloud outside the box

tl;dr: Install @aws/durable-execution-sdk-js-testing, set skipTime: true, and drive callbacks from your test. Treat a manager’s “no” as an answer your workflow handles, and a failed callback as an error that fails the execution. Wait on callbacks with waitForData(), not SUBMITTED. Fake time skips context.wait but not callback timeouts.

Why a test runner

Lambda durable functions checkpoint each step, suspend on a wait or a callback, and resume where they left off. That makes them hard to test by deploying. A workflow that waits five minutes for an approval takes five minutes per test run, and you need something to send the approval.

In What can you test locally with AWS Emulator? we found that the emulator deploys durable functions and starts executions, but cannot run the durable SDK. AWS publishes a test runner for the other half. LocalDurableTestRunner runs your handler in-process against its own checkpoint server, with fake timers, and records every operation so you can assert on the history.

We tested version 1.3.2 of the runner with version 3.0.0 of the durable SDK and vitest 5.1.0.

The workflow

A checkout reserves stock, waits five seconds, asks a manager to approve, and charges the card if they said yes:

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

export const handler = withDurableExecution(async (event, context) => {
  const order = await context.step('reserve-stock', async () => ({ orderId: event.orderId }));

  await context.wait({ seconds: 5 });

  const [approval, callbackId] = await context.createCallback('manager-approval', {
    timeout: { minutes: 5 },
  });
  // Send callbackId to the approver here; they answer with SendDurableExecutionCallbackSuccess.
  await context.step('request-approval', async () => callbackId);

  // A failed or timed-out callback throws here and fails the execution. A "no" is an answer.
  const decision = JSON.parse(await approval);

  if (!decision.approved) {
    return { orderId: order.orderId, status: 'rejected', reason: decision.reason };
  }

  await context.step('charge-card', async () => ({ charged: true }));

  return { orderId: order.orderId, status: 'charged' };
});

Two kinds of “no” reach this code, and a payment workflow must keep them apart.

A manager who declines sends a successful callback with approved: false. The workflow got its answer. It returns rejected, the execution succeeds, and the card stays untouched.

An approval service that errors, or a manager who never answers, makes the callback fail. await approval throws, the execution fails, and you can retry it or alert someone. Our first version charged the card on any successful callback, so a “no” from the manager still reached charge-card. Check the decision before you take the money.

The callback result arrives as the string the approver sent, so the handler parses it with JSON.parse.

The tests

npm install --save-dev @aws/durable-execution-sdk-js-testing vitest

A small helper starts the run, waits for the callback to open, answers it and collects the step names:

import { afterAll, beforeAll, expect, test } from 'vitest';
import { LocalDurableTestRunner } from '@aws/durable-execution-sdk-js-testing';
import { handler } from './index.mjs';

beforeAll(() => LocalDurableTestRunner.setupTestEnvironment({ skipTime: true }));
afterAll(() => LocalDurableTestRunner.teardownTestEnvironment());

async function checkout(orderId, answer) {
  const runner = new LocalDurableTestRunner({ handlerFunction: handler });
  const approval = runner.getOperation('manager-approval');

  const running = runner.run({ payload: { orderId } });
  await approval.waitForData();
  await answer?.(approval);
  const execution = await running;

  const steps = execution.getOperations().map((op) => op.getName());
  return { execution, steps };
}

test('charges the card when the manager approves', async () => {
  const { execution, steps } = await checkout('1001', (approval) =>
    approval.sendCallbackSuccess(JSON.stringify({ approved: true })),
  );

  expect(execution.getStatus()).toBe('SUCCEEDED');
  expect(execution.getResult()).toEqual({ orderId: '1001', status: 'charged' });
  expect(steps).toContain('charge-card');
});

test('does not charge when the manager says no', async () => {
  const { execution, steps } = await checkout('1002', (approval) =>
    approval.sendCallbackSuccess(JSON.stringify({ approved: false, reason: 'over budget' })),
  );

  expect(execution.getStatus()).toBe('SUCCEEDED');
  expect(execution.getResult()).toEqual({ orderId: '1002', status: 'rejected', reason: 'over budget' });
  expect(steps).not.toContain('charge-card');
});

test('fails without charging when the approval system errors', async () => {
  const { execution, steps } = await checkout('1003', (approval) =>
    approval.sendCallbackFailure({ errorMessage: 'Approval service unavailable' }),
  );

  expect(execution.getStatus()).toBe('FAILED');
  expect(execution.getError()).toMatchObject({ errorType: 'CallbackExternalError' });
  expect(steps).not.toContain('charge-card');
});
 Test Files  1 passed (1)
      Tests  3 passed (3)

The suite took 0.94 seconds of wall-clock time, five-second wait included. On the approval path the runner records reserve-stock, the wait, manager-approval, request-approval and charge-card, each SUCCEEDED. The other two paths stop before charge-card.

Four things we learned

Wait for the callback with waitForData()

The runner’s README shows waitForData(WaitingOperationStatus.SUBMITTED) before you send a callback. That status belongs to waitForCallback, which takes a submitter function. On a createCallback operation, version 1.3.2 never resolves it, and your test hangs until it times out. We saw the hang under vitest, node --test and a plain Node script. waitForData() with no argument waits for the operation to start, and that is enough to answer it.

Fake time skips waits, not callback timeouts

skipTime: true installs fake timers. A one-hour context.wait finished in 0.2 seconds of real time. A callback timeout did not move: a three-second timeout took 3.2 seconds, and a five-minute timeout was still waiting when we stopped it after 25 seconds. If you want a test for the “nobody answered” path, give that test a short timeout, and expect it to take that long.

The fake clock changes your timings

Date.now() and performance.now() read the fake clock. Read Date.now() before the wait and after it, and you see a gap of about 5,080 ms in a run that took a few milliseconds. Time your tests from the shell.

The emulator cannot replace this

AWS Emulator 1.0.385 runs Lambda JavaScript in its own engine, which lacks the node:async_hooks module the durable SDK imports. Deploy to the emulator to check that your stack configures durable functions. Use this runner to test what they do, and an AWS account to test them against real services.

Conclusion

Three tests cover the three answers a payment approval can get: yes, no and an error. They run in under a second, need no AWS account and no emulator, and assert on the operation history as well as the result. Add them to your unit tests and keep deployed durable workflows for integration testing.

For where this sits next to AWS Emulator and ephemeral environments, read What can you test locally with AWS Emulator?. For more on giving tests a real system to run against, read Give AI somewhere to prove its work.

awstestingtypescript