Skip to content

Provide a first-class testing story for the durable-functions (v4) compat package #304

Description

@YunchuWang

Summary

The new durable-functions v4 compat package (added in #282, built on the @microsoft/durabletask-js gRPC core) currently ships no first-class testing story. This is a regression in developer experience versus v3 (azure-functions-durable-js), where DummyOrchestrationContext / DummyEntityContext let you unit‑test an orchestrator/entity in one line with no host, no Docker, no network.

This issue proposes a durable-functions/testing subpath entry point that gives customers an elegant, Functions‑native way to test v4 orchestrators, entities, and activities — without importing core internals or knowing about wrapOrchestrator.

Filed as a fast‑follow to #282 (does not need to block that PR).


Background: how v3 testing worked (the bar we are regressing from)

v3 exposed fake contexts specifically for the Azure Functions programming model:

  • azure-functions-durable-jssrc/util/testingUtils.ts: DummyOrchestrationContext extends InvocationContext, DummyEntityContext<T>.
  • Real usage: test/integration/orchestrator-spec.ts, test/integration/entity-spec.ts.

Orchestrator test (v3):

const orchestrator = TestOrchestrations.SayHelloInline;
const mockContext = new DummyOrchestrationContext();
const input = new DurableOrchestrationInput("", TestHistories.GetOrchestratorStart(...), name);
const result = await orchestrator(input, mockContext);
expect(result).to.deep.equal(new OrchestratorState({ isDone: true, output: "Hello, World!", ... }, true));

Entity test (v3):

const mockContext = new DummyEntityContext<string>();
const result = await entity(testData.input, mockContext);
entityStateMatchesExpected(result, testData.output);

One new Dummy*Context() + a hand-built history/batch + a single synchronous call. That is the ergonomic bar.


Current state in v4 (the problem)

  1. The compat package exports zero testing utilities. packages/azure-functions-durable/src/index.ts only re-exports runtime types + DurableFunctionsWorker. There is no Dummy*, no Test*, no InMemory*.

  2. The only way to drive a wrapped orchestrator today is to reach into core internals, exactly as the package's own e2e tests do — see packages/azure-functions-durable/test/unit/orchestration-context.spec.ts (describe("wrapOrchestrator end-to-end (real core executor)")):

import { InMemoryOrchestrationBackend, TestOrchestrationWorker, TestOrchestrationClient } from "@microsoft/durabletask-js";
import { wrapOrchestrator } from "../../src/orchestration-context"; // internal path

const backend = new InMemoryOrchestrationBackend();
const worker  = new TestOrchestrationWorker(backend);
worker.addNamedActivity("echo", echo);
worker.addNamedOrchestrator("orch", wrapOrchestrator(myOrch)); // customer must know to wrap
await worker.start();
const client = new TestOrchestrationClient(backend);
const id = await client.scheduleNewOrchestration("orch", "IN");
const state = await client.waitForOrchestrationCompletion(id, true, 10);
expect(state?.serializedOutput).toBe(JSON.stringify("classic-done:echo:IN")); // manual (de)serialization

Four DX problems for customers:

  • Imports from the @microsoft/durabletask-js core package, not from durable-functions.
  • Requires knowing about wrapOrchestrator — an internal bridge concept.
  • 5+ lines of backend/worker/client boilerplate per test.
  • No entity story at all. entity-context.spec.ts can only white-box entities via mocked EntityFactory / TaskEntityOperation; there is no DummyEntityContext equivalent.

Design constraint: why orchestrator can't be a v3-style drop-in (and entity can)

Orchestrator. v3's DummyContext worked because the v3 SDK is the replay engine and history is an input parameter (TaskOrchestrationExecutor.execute(context, history[], fn) iterates the array and drives the generator via .next()/.throw()). v4 has no "history array in → run synchronously to completion" seam: history lives inside InMemoryOrchestrationBackend and is advanced by the engine across multiple turns. So the honest v4 replacement is a real in-memory engine run, not a fake context. We should not resurrect a synthetic single-shot DummyOrchestrationContext for orchestrators — it would misrepresent the execution model.

Entity. Entities are request/response batch processing, structurally identical to v3's GetStringStoreBatch. This can be a synchronous single-shot call, so we can and should restore v3-level ergonomics here (Layer 3 below).


Proposed solution: a durable-functions/testing subpath

Add a dedicated testing entry point so customers speak only the Functions model — no core imports, no wrapOrchestrator knowledge. Everything below is a thin wrapper over already-existing building blocks (wrapOrchestrator, wrapEntity + ClassicEntityAdapter, and core InMemoryOrchestrationBackend / TestOrchestrationWorker / TestOrchestrationClient). No core changes required.

Layer 1 — one-shot helpers (covers ~80% of tests)

import { test } from "durable-functions/testing";

const r = await test.runOrchestrator(myOrchestrator, {
  input: "World",
  activities: { SayHello: (ctx, name) => `Hello, ${name}!` }, // stub activities inline
});
expect(r.status).toBe("Completed");
expect(r.output).toBe("Hello, World!"); // already deserialized — no manual JSON.parse

Layer 2 — harness (for interactive cases: raise event / advance timer / terminate)

const h = test.createOrchestrationHarness();
h.registerOrchestrator("approval", approvalOrch);
h.registerActivity("notify", async () => "sent");

const run = await h.start("approval", { input: {...} });
await run.raiseEvent("Approved", true);
await run.waitForCompletion();
expect(run.output).toEqual({...});
expect(run.actionsInclude("notify")).toBe(true);

Layer 3 — entity (restore v3-style synchronous ergonomics)

const r = await test.runEntity(counterEntity, {
  initialState: 0,
  operations: [{ name: "add", input: 5 }, { name: "get" }],
});
expect(r.state).toBe(5);
expect(r.results).toEqual([undefined, 5]);

Internals: wrapEntity(counterEntity) → build a TaskEntityOperation[] batch → run once through ClassicEntityAdapter → collect { state, results }. This closes the entity gap left by dropping DummyEntityContext.

Packaging

package.json currently exposes only ".". Add a subpath:

"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" }
}

Keeping it on a subpath means the testing helpers (and their transitive dev surface) never leak into the runtime import graph.


Priority / scope

Capability Reuses (existing) New code Value Priority
test.runActivity(fn, input) none (direct call) tiny med P1
test.runOrchestrator (Layer 1) wrapOrchestrator + core testing small wrapper high P0
test.createOrchestrationHarness (Layer 2) + client event/terminate APIs medium high P1
test.runEntity (Layer 3) wrapEntity + ClassicEntityAdapter + batch driver medium high (fills entity gap) P0
durable-functions/testing subpath export package.json exports tiny high P0

None of these require changes to @microsoft/durabletask-js core.


Interim mitigation (cheap, could even land in #282)

Before the full testing subpath exists, re-export the core testing primitives + the wrap helpers from the compat package and document a "How to test" section in the README, so customers at least stop importing from the core package and internal paths:

  • re-export InMemoryOrchestrationBackend, TestOrchestrationWorker, TestOrchestrationClient (from core) and wrapOrchestrator / wrapEntity.
  • README: one worked orchestrator example + one entity example.

Non-goals

  • No synthetic single-shot DummyOrchestrationContext for orchestrators. It cannot faithfully model v4's multi-turn engine execution; the in-memory engine run is the correct replacement.
  • No changes to @microsoft/durabletask-js core testing APIs.

Notes / corrections

  • Core exposes exactly three testing primitives (packages/durabletask-js/src/testing/index.ts): InMemoryOrchestrationBackend, TestOrchestrationClient, TestOrchestrationWorker. There is no TestEntityWorker — entity end-to-end testing is the real gap this issue's Layer 3 addresses.
  • Reference for the intended orchestrator pattern: examples/azure-managed/unit-testing/index.ts.

References

  • PR Add Functions gRPC core helpers #282 (adds the durable-functions compat package)
  • packages/azure-functions-durable/src/orchestration-context.tswrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator)
  • packages/azure-functions-durable/src/entity-context.tswrapEntity(...), ClassicEntityAdapter implements ITaskEntity
  • packages/azure-functions-durable/test/unit/orchestration-context.spec.ts — current e2e pattern
  • packages/durabletask-js/src/testing/* — core in-memory testing primitives

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions