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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions