# Test Lambda durable functions without deploying

By Jag Reehal, 2026-11-16. Canonical: https://arrangeactassert.com/posts/test-lambda-durable-functions-without-deploying/

<!-- Excerpt Start -->

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](/_astro/test-lambda-durable-functions-without-deploying.CePG3v4Y_2l7loK.webp)

<!-- Excerpt End -->

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](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html) 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?](/posts/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:

```js
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

```bash
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:

```js
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');
});
```

```text
 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?](/posts/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](/posts/give-ai-somewhere-to-prove-its-work/).