Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

### New

### Fixes
- Added a `durable-functions/testing` entry point with `runOrchestrator`, which runs an orchestrator
to a terminal state against inline activity implementations on the in-memory backend and always
releases its worker, and `createActivityContext` for invoking activity handlers directly.
Interactive scenarios (external events, termination, suspend/resume) and entity batches are
covered by driving the `@microsoft/durabletask-js` in-memory test stack with `wrapOrchestrator` /
`wrapEntity`; see the README.

### Fixes

## v4.0.0-beta.1 (2026-07-31)

Expand Down
109 changes: 104 additions & 5 deletions packages/azure-functions-durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,13 @@ changed:
**not** available — existing `response.getHeader(...)` calls **fail at runtime** and must be
rewritten to index `response.headers[...]` by lower-cased key (response header names are lower-cased
by `fetch`).
- **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext`
(testing utilities) and the entity-lock types above. `TaskFailedError`
is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the
core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests.
- **The v3 dummy contexts were replaced by `durable-functions/testing`.** The new helpers run
orchestrators through the real in-memory replay engine and run entity batches directly, without a
Functions host or imports from `@microsoft/durabletask-js`. The entity-lock types above remain
removed. `TaskFailedError` is re-exported from the core SDK (aggregate failures surface as
JS-native `AggregateError`).
- **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator
written as a *synchronous, single-argument, non-generator* function `(context) => context.df.*`
written as a _synchronous, single-argument, non-generator_ function `(context) => context.df.*`
(one that never `yield`s) is now treated as a **core-native** orchestrator and receives the core
`OrchestrationContext`, which has no `.df`. This resolves
[#321](https://github.com/microsoft/durabletask-js/issues/321), where a core-native
Expand Down Expand Up @@ -149,6 +150,104 @@ app.http("startHello", {
});
```

## Testing

`durable-functions/testing` provides one helper for the common case — running an orchestrator to
completion against fake activities — plus a factory for the activity invocation context. Everything
else is already covered by the in-memory test stack in `@microsoft/durabletask-js`.

### Activities

Activity handlers are ordinary Azure Functions handlers with no durable state, so call them directly
and pass a context:

```typescript
import type { InvocationContext } from "@azure/functions";
import { createActivityContext } from "durable-functions/testing";

const sayHello = (name: string, context: InvocationContext) => `${context.functionName}: Hello, ${name}!`;

expect(await sayHello("World", createActivityContext("sayHello"))).toBe("sayHello: Hello, World!");
```

### Orchestrations

`runOrchestrator` registers the orchestrator and the supplied activities on an in-memory worker, runs
the instance to a terminal state, and stops the worker before returning:

```typescript
import type { OrchestrationContext } from "durable-functions";
import { OrchestrationRuntimeStatus } from "durable-functions";
import { runOrchestrator } from "durable-functions/testing";

const helloOrchestrator = function* (context: OrchestrationContext) {
const name = context.df.getInput<string>();
return yield context.df.callActivity("sayHello", name);
};

const result = await runOrchestrator<string>(helloOrchestrator, {
input: "World",
activities: {
sayHello: (name: unknown) => `Hello, ${String(name)}!`,
},
});

expect(result.runtimeStatus).toBe(OrchestrationRuntimeStatus.Completed);
expect(result.output).toBe("Hello, World!");
```

`runtimeStatus`, `output`, and `customStatus` are produced by the same mapping `client.getStatus()`
applies at runtime, so a test asserts on the values a deployed client would observe. A failed run
returns `OrchestrationRuntimeStatus.Failed` together with `failure` (`errorType`, `message`,
`stackTrace`).

`runOrchestrator` intentionally has no forced timeout. It returns only after the orchestration is
terminal and the worker has drained, so activity code cannot keep mutating test state afterwards.
Arbitrary JavaScript promises cannot be cancelled: if a handler never settles, the helper stays
pending and the test runner's own timeout applies.

Durable timers run on **real wall-clock delays** — the in-memory backend has no virtual clock, so
keep timer delays short in tests.

### Interactive scenarios and entities

External events, termination, suspend/resume, and entity batches are not wrapped. Drive the core
in-memory stack directly and register Durable Functions handlers with `wrapOrchestrator` (or
`wrapEntity`):

```typescript
import {
InMemoryOrchestrationBackend,
TestOrchestrationClient,
TestOrchestrationWorker,
} from "@microsoft/durabletask-js";
import { toDurableOrchestrationStatus, wrapOrchestrator } from "durable-functions";

const backend = new InMemoryOrchestrationBackend();
const worker = new TestOrchestrationWorker(backend);
const client = new TestOrchestrationClient(backend);

worker.addNamedOrchestrator(
"approval",
wrapOrchestrator(function* (context) {
return { approved: yield context.df.waitForExternalEvent<boolean>("approved") };
}),
);
await worker.start();

try {
const instanceId = await client.scheduleNewOrchestration("approval", undefined, "approval-1");
await client.waitForOrchestrationStart(instanceId, true, 10);
await client.raiseOrchestrationEvent(instanceId, "approved", true);

const state = await client.waitForOrchestrationCompletion(instanceId, true, 10);
expect(toDurableOrchestrationStatus(state!).output).toEqual({ approved: true });
} finally {
await worker.stop();
backend.reset();
}
```

### Client (starter) functions

`app.client.*` is sugar for the client-starter above — it adds the `durableClient` input binding and
Expand Down
12 changes: 12 additions & 0 deletions packages/azure-functions-durable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@
"description": "Azure Functions Durable provider for the Durable Task JavaScript SDK",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"testing": [
"./dist/testing/index.d.ts"
]
}
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"require": "./dist/testing/index.js",
"import": "./dist/testing/index.js"
}
},
"files": [
Expand Down
109 changes: 109 additions & 0 deletions packages/azure-functions-durable/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { InvocationContext } from "@azure/functions";
import {
InMemoryOrchestrationBackend,
TestOrchestrationClient,
TestOrchestrationWorker,
} from "@microsoft/durabletask-js";
import type { TaskFailureDetails } from "@microsoft/durabletask-js";
import type { ActivityHandler, OrchestrationHandler } from "../app";
import { wrapOrchestrator } from "../orchestration-context";
import { OrchestrationRuntimeStatus, toDurableOrchestrationStatus } from "../orchestration-status";

const ORCHESTRATOR_NAME = "orchestrator";
const DEFAULT_ACTIVITY_NAME = "activity";

/**
* Creates the {@link InvocationContext} an activity handler receives at runtime.
*
* @remarks Activities are ordinary Azure Functions handlers with no durable state, so they are
* tested by calling them directly: `await sayHello("World", createActivityContext("sayHello"))`.
*/
export function createActivityContext(functionName: string = DEFAULT_ACTIVITY_NAME): InvocationContext {
return new InvocationContext({ functionName });
}

/** Options for {@link runOrchestrator}. */
export interface OrchestratorTestOptions<TInput = unknown> {
/** Input passed to the orchestrator. */
input?: TInput;
/** Instance id to schedule under. Defaults to a generated id. */
instanceId?: string;
/** Activity implementations the orchestrator may call, keyed by activity name. */
activities?: Readonly<Record<string, ActivityHandler>>;
}

/**
* The terminal state of an orchestration run.
*
* @remarks `runtimeStatus`, `output`, and `customStatus` are produced by the same mapping
* `DurableFunctionsClient.getStatus` applies at runtime, so a test asserts on exactly the values a
* deployed client would observe.
*/
export interface OrchestrationTestResult<TOutput = unknown> {
instanceId: string;
runtimeStatus: OrchestrationRuntimeStatus;
output?: TOutput;
customStatus?: unknown;
/** Populated when the orchestration failed. */
failure?: TaskFailureDetails;
}

/**
* Runs one orchestrator to a terminal state on the in-memory backend and always releases its worker.
*
* @remarks Durable timers use real wall-clock time, because the in-memory backend has no virtual
* clock; tests should schedule short delays. There is no forced timeout: the helper returns only
* once the orchestration is terminal and the worker has drained, so activity code cannot keep
* mutating test state after it returns. Arbitrary JavaScript promises cannot be cancelled, so a
* handler that never settles leaves this helper pending and the test runner's timeout applies.
*
* Interactive scenarios (external events, terminate, suspend/resume) are covered by driving
* `TestOrchestrationWorker` and `TestOrchestrationClient` from `@microsoft/durabletask-js` directly
* and registering handlers with {@link wrapOrchestrator}; see the package README.
*/
export async function runOrchestrator<TOutput = unknown, TInput = unknown>(
handler: OrchestrationHandler,
options: OrchestratorTestOptions<TInput> = {},
): Promise<OrchestrationTestResult<TOutput>> {
const backend = new InMemoryOrchestrationBackend();
const worker = new TestOrchestrationWorker(backend);
const client = new TestOrchestrationClient(backend);

worker.addNamedOrchestrator(ORCHESTRATOR_NAME, wrapOrchestrator(handler));
for (const [name, activity] of Object.entries(options.activities ?? {})) {
worker.addNamedActivity(name, async (_context, input) => activity(input, createActivityContext(name)));
}

await worker.start();
try {
const instanceId = await client.scheduleNewOrchestration(ORCHESTRATOR_NAME, options.input, options.instanceId);
// A zero timeout disables the backend's wait timer, per the no-forced-timeout remark above.
const state = await client.waitForOrchestrationCompletion(instanceId, true, 0);
if (!state) {
throw new Error(`Orchestration '${instanceId}' was not found.`);
}

const status = toDurableOrchestrationStatus(state);
return {
instanceId: status.instanceId,
runtimeStatus: status.runtimeStatus,
output: status.output as TOutput | undefined,
customStatus: status.customStatus,
failure: state.failureDetails
? {
errorType: state.failureDetails.errorType,
message: state.failureDetails.message,
stackTrace: state.failureDetails.stackTrace,
}
: undefined,
};
} finally {
// Stopping the worker drains the in-flight orchestrator/activity handler; resetting the backend
// clears durable timers that would otherwise keep the process alive after the test.
await worker.stop();
backend.reset();
}
}
46 changes: 46 additions & 0 deletions packages/azure-functions-durable/test/unit/compat-exports.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import ts from "typescript";
import type {
ActivityHandler,
DurableClient,
Expand All @@ -10,6 +14,7 @@ import type {
OrchestrationHandler,
} from "../../src";
import { TaskFailedError } from "../../src";
import packageJson from "../../package.json";

describe("v3 compatibility type aliases", () => {
it("exposes ActivityHandler / OrchestrationHandler / OrchestrationContext", () => {
Expand All @@ -22,6 +27,47 @@ describe("v3 compatibility type aliases", () => {
expect(typeof orchestrator).toBe("function");
});

describe("package exports", () => {
it("publishes the testing subpath independently from the runtime entry point", () => {
const exports = packageJson.exports as Record<string, unknown>;
expect(exports["./testing"]).toEqual({
types: "./dist/testing/index.d.ts",
require: "./dist/testing/index.js",
import: "./dist/testing/index.js",
});
expect(packageJson.typesVersions).toEqual({
"*": {
testing: ["./dist/testing/index.d.ts"],
},
});
});

it("resolves testing declarations with classic Node module resolution", () => {
const consumerRoot = mkdtempSync(join(tmpdir(), "durable-functions-types-"));
const packageRoot = join(consumerRoot, "node_modules", "durable-functions");
const declarationPath = join(packageRoot, "dist", "testing", "index.d.ts");
const consumerPath = join(consumerRoot, "consumer.ts");

try {
mkdirSync(join(packageRoot, "dist", "testing"), { recursive: true });
writeFileSync(join(packageRoot, "package.json"), JSON.stringify(packageJson));
writeFileSync(declarationPath, "export declare function runOrchestrator(): Promise<void>;");
writeFileSync(consumerPath, 'import { runOrchestrator } from "durable-functions/testing";');

const resolved = ts.resolveModuleName(
"durable-functions/testing",
consumerPath,
{ moduleResolution: ts.ModuleResolutionKind.Node10 },
ts.sys,
).resolvedModule;

expect(resolved?.resolvedFileName.replace(/\\/g, "/")).toBe(declarationPath.replace(/\\/g, "/"));
} finally {
rmSync(consumerRoot, { recursive: true, force: true });
}
});
});

it("exposes generic EntityContext<TState> / EntityHandler<TState> and DurableClient", () => {
// Compile-guard: the generic aliases must accept a type argument (the legacy v3 surface uses
// e.g. EntityHandler<string>), even though our underlying types are non-generic.
Expand Down
Loading
Loading