Skip to content

Commit 4d90fc6

Browse files
committed
Add durable entity unit testing support
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 643dfa24-59f4-4119-9377-0d9e488df141
1 parent b871df8 commit 4d90fc6

5 files changed

Lines changed: 420 additions & 4 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
ADDED
1111

12+
- Added `azure.durable_functions.testing.execute_entity()` for unit testing
13+
function-style and class-based entities without a Functions host or Durable Task
14+
backend. The helper returns the operation result, resulting state, and typed
15+
signal/orchestration actions.
1216
- Added `SyncDurableFunctionsClient`. `DFApp.durable_client_input()` now injects
1317
the synchronous client into synchronous functions and the asynchronous client
1418
into coroutine functions. Both clients support scheduled-task and history-export

‎azure-functions-durable/README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,41 @@ Key capabilities include durable orchestrations and sub-orchestrations, durable
3333
timers, external events, durable entities, retries, versioning, durable HTTP
3434
calls (`context.call_http(...)`), recurring scheduled tasks, and history export.
3535

36+
## Unit testing entities
37+
38+
Use `execute_entity()` to run one entity operation in-process without a
39+
Functions host or Durable Task backend. It supports v1-style entity functions,
40+
durabletask-native entity functions, and `DurableEntity` subclasses:
41+
42+
```python
43+
from azure.durable_functions.testing import execute_entity
44+
from durabletask.entities import DurableEntity
45+
46+
47+
class Counter(DurableEntity):
48+
def add(self, amount: int) -> int:
49+
value = self.get_state(int, 0) + amount
50+
self.set_state(value)
51+
return value
52+
53+
54+
outcome = execute_entity(Counter, "add", input=2, state=3)
55+
56+
assert outcome.result == 5
57+
assert outcome.state == 5
58+
assert outcome.actions == ()
59+
```
60+
61+
For an `entity_trigger`-decorated function, pass the exposed entity function:
62+
63+
```python
64+
entity_function = counter.build().get_user_function().entity_function
65+
outcome = execute_entity(entity_function, "add", input=2, state=3)
66+
```
67+
68+
The returned `EntityTestResult` includes the operation result, resulting state,
69+
and typed signal or orchestration-start actions scheduled by the operation.
70+
3671
## Links
3772

3873
- [Changelog](CHANGELOG.md)

‎azure-functions-durable/azure/durable_functions/testing/__init__.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@
33

44
"""Unit-testing utilities for Azure Durable Functions.
55
6-
These helpers let you exercise orchestrator business logic in a plain unit test
7-
without a running Functions host or a Durable Task backend. They mirror the
8-
``azure.durable_functions.testing`` surface from the v1 SDK so existing tests
9-
keep working against v2.x.
6+
These helpers let you exercise orchestrator and entity business logic in a
7+
plain unit test without a running Functions host or a Durable Task backend.
108
"""
119

10+
from .entity import (
11+
EntityAction,
12+
EntitySignalAction,
13+
EntityTestResult,
14+
OrchestrationStartAction,
15+
execute_entity,
16+
)
1217
from .orchestrator_generator_wrapper import orchestrator_generator_wrapper
1318

1419
__all__ = [
20+
"EntityAction",
21+
"EntitySignalAction",
22+
"EntityTestResult",
23+
"OrchestrationStartAction",
24+
"execute_entity",
1525
"orchestrator_generator_wrapper",
1626
]
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""In-process execution support for unit-testing durable entities."""
5+
6+
from __future__ import annotations
7+
8+
import inspect
9+
from dataclasses import dataclass
10+
from datetime import datetime, timezone
11+
from typing import Any, Callable, TypeAlias
12+
13+
from durabletask.entities import DurableEntity, EntityContext, EntityInstanceId
14+
from durabletask.internal import type_discovery
15+
from durabletask.internal.entity_state_shim import StateShim
16+
import durabletask.internal.orchestrator_service_pb2 as pb
17+
18+
from ..internal.compat.entity_context import wrap_entity
19+
from ..internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
20+
21+
22+
@dataclass(frozen=True)
23+
class EntitySignalAction:
24+
"""A signal scheduled by an entity operation."""
25+
26+
entity_id: EntityInstanceId
27+
operation: str
28+
input: Any = None
29+
scheduled_time: datetime | None = None
30+
31+
32+
@dataclass(frozen=True)
33+
class OrchestrationStartAction:
34+
"""An orchestration start scheduled by an entity operation."""
35+
36+
name: str
37+
instance_id: str
38+
input: Any = None
39+
40+
41+
EntityAction: TypeAlias = EntitySignalAction | OrchestrationStartAction
42+
43+
44+
@dataclass(frozen=True)
45+
class EntityTestResult:
46+
"""The observable outcome of executing one entity operation."""
47+
48+
result: Any
49+
state: Any
50+
actions: tuple[EntityAction, ...]
51+
52+
53+
def execute_entity(
54+
entity: Callable[..., Any],
55+
operation: str,
56+
input: Any = None,
57+
state: Any = None,
58+
*,
59+
entity_name: str | None = None,
60+
entity_key: str = "test",
61+
) -> EntityTestResult:
62+
"""Execute one entity operation without a Functions host or backend.
63+
64+
The ``entity`` argument may be a v1-style one-argument function, a
65+
durabletask-native two-argument function, or a
66+
:class:`~durabletask.entities.DurableEntity` subclass. Decorated entities can
67+
be obtained from the function builder's exposed ``entity_function`` handle.
68+
69+
Parameters
70+
----------
71+
entity : Callable[..., Any]
72+
The entity function or ``DurableEntity`` subclass to execute.
73+
operation : str
74+
The entity operation name.
75+
input : Any, optional
76+
The operation input.
77+
state : Any, optional
78+
The state visible at the start of the operation.
79+
entity_name : str, optional
80+
The entity name exposed through its context. Defaults to the configured
81+
durable entity name or the callable's ``__name__``.
82+
entity_key : str, optional
83+
The entity key exposed through its context. Defaults to ``"test"``.
84+
85+
Returns
86+
-------
87+
EntityTestResult
88+
The operation result, resulting state, and scheduled actions.
89+
90+
Raises
91+
------
92+
AttributeError
93+
If a class-based entity does not define ``operation``.
94+
TypeError
95+
If the entity or operation is not callable.
96+
Exception
97+
Any exception raised by entity code or payload serialization.
98+
"""
99+
if not callable(entity):
100+
raise TypeError("entity must be a callable or DurableEntity subclass")
101+
102+
entity_callable = wrap_entity(entity)
103+
resolved_name = (
104+
entity_name
105+
or getattr(entity_callable, "__durable_entity_name__", None)
106+
or getattr(entity_callable, "__name__", None)
107+
)
108+
if not resolved_name:
109+
raise ValueError(
110+
"entity_name is required when the entity has no __name__")
111+
112+
converter = DEFAULT_FUNCTIONS_DATA_CONVERTER
113+
entity_id = EntityInstanceId(resolved_name, entity_key)
114+
state_shim = StateShim(state, converter)
115+
context = EntityContext(
116+
str(entity_id), operation, state_shim, entity_id, converter)
117+
118+
encoded_input = converter.serialize(input)
119+
input_type = (
120+
type_discovery.entity_input_type(
121+
entity_callable, operation, converter)
122+
if encoded_input is not None
123+
else None
124+
)
125+
operation_input = converter.deserialize(encoded_input, input_type)
126+
127+
try:
128+
result = _invoke_entity(
129+
entity_callable, operation, context, operation_input)
130+
encoded_result = converter.serialize(result)
131+
result = converter.deserialize(encoded_result)
132+
state_shim.commit()
133+
except Exception:
134+
state_shim.rollback()
135+
raise
136+
137+
actions = tuple(
138+
_decode_action(action) for action in state_shim.get_operation_actions())
139+
return EntityTestResult(
140+
result=result,
141+
state=state_shim.get_state(),
142+
actions=actions,
143+
)
144+
145+
146+
def _invoke_entity(
147+
entity: Callable[..., Any],
148+
operation: str,
149+
context: EntityContext,
150+
operation_input: Any,
151+
) -> Any:
152+
if isinstance(entity, type) and issubclass(entity, DurableEntity):
153+
instance = entity()
154+
if not hasattr(instance, operation):
155+
raise AttributeError(
156+
f"Entity '{context.entity_id}' does not have operation "
157+
f"'{operation}'")
158+
159+
method = getattr(instance, operation)
160+
if not callable(method):
161+
raise TypeError(f"Entity operation '{operation}' is not callable")
162+
163+
instance._initialize_entity_context( # pyright: ignore[reportPrivateUsage]
164+
context)
165+
signature = inspect.signature(method)
166+
has_required_parameter = any(
167+
parameter.default is inspect.Parameter.empty
168+
for parameter in signature.parameters.values()
169+
if parameter.kind not in (
170+
inspect.Parameter.VAR_POSITIONAL,
171+
inspect.Parameter.VAR_KEYWORD,
172+
)
173+
)
174+
if has_required_parameter or operation_input is not None:
175+
return method(operation_input)
176+
return method()
177+
178+
return entity(context, operation_input)
179+
180+
181+
def _decode_action(action: pb.OperationAction) -> EntityAction:
182+
converter = DEFAULT_FUNCTIONS_DATA_CONVERTER
183+
if action.HasField("sendSignal"):
184+
signal = action.sendSignal
185+
scheduled_time = (
186+
signal.scheduledTime.ToDatetime(tzinfo=timezone.utc)
187+
if signal.HasField("scheduledTime")
188+
else None
189+
)
190+
encoded_input = (
191+
signal.input.value if signal.HasField("input") else None)
192+
return EntitySignalAction(
193+
entity_id=EntityInstanceId.parse(signal.instanceId),
194+
operation=signal.name,
195+
input=converter.deserialize(encoded_input),
196+
scheduled_time=scheduled_time,
197+
)
198+
199+
if action.HasField("startNewOrchestration"):
200+
start = action.startNewOrchestration
201+
encoded_input = start.input.value if start.HasField("input") else None
202+
return OrchestrationStartAction(
203+
name=start.name,
204+
instance_id=start.instanceId,
205+
input=converter.deserialize(encoded_input),
206+
)
207+
208+
raise ValueError("Unsupported entity action")

0 commit comments

Comments
 (0)