Skip to content

Commit b0570f0

Browse files
authored
Add change supporting unit testing (#537)
* Add change supporting unit testing - Support unit testing client functions, orchestrators, entities - Add unit tests to samples and add pipeline step to check tests
1 parent 7d1a993 commit b0570f0

23 files changed

+610
-26
lines changed

.github/workflows/validate.yml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,30 @@ jobs:
3131
flake8 . --count --show-source --statistics
3232
- name: Run tests
3333
run: |
34-
pytest
34+
pytest --ignore=samples-v2
35+
36+
test-samples:
37+
strategy:
38+
matrix:
39+
app_name: [blueprint, fan_in_fan_out, function_chaining]
40+
runs-on: ubuntu-latest
41+
defaults:
42+
run:
43+
working-directory: ./samples-v2/${{ matrix.app_name }}
44+
steps:
45+
- name: Checkout repository
46+
uses: actions/checkout@v2
47+
48+
- name: Set up Python
49+
uses: actions/setup-python@v2
50+
with:
51+
python-version: 3.9
52+
- name: Install dependencies
53+
run: |
54+
python -m pip install --upgrade pip
55+
pip install -r requirements.txt
56+
pip install -r ../../requirements.txt
57+
pip install ../.. --no-cache-dir --upgrade --no-deps --force-reinstall
58+
- name: Run tests
59+
run: |
60+
python -m pytest

azure/durable_functions/decorators/durable_app.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ async def df_client_middleware(*args, **kwargs):
198198
# Invoke user code with rich DF Client binding
199199
return await user_code(*args, **kwargs)
200200

201+
# Todo: This feels awkward - however, there are two reasons that I can't naively implement
202+
# this in the same way as entities and orchestrators:
203+
# 1. We intentionally wrap this exported signature with @wraps, to preserve the original
204+
# signature of the user code. This means that we can't just assign a new object to the
205+
# fb._function._func, as that would overwrite the original signature.
206+
# 2. I have not yet fully tested the behavior of overriding __call__ on an object with an
207+
# async method.
208+
# Here we lose type hinting and auto-documentation - not great. Need to find a better way
209+
# to do this.
210+
df_client_middleware.client_function = fb._function._func
211+
201212
user_code_with_rich_client = df_client_middleware
202213
fb._function._func = user_code_with_rich_client
203214

azure/durable_functions/entity.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,52 @@
33
from datetime import datetime
44
from typing import Callable, Any, List, Dict
55

6+
import azure.functions as func
7+
68

79
class InternalEntityException(Exception):
810
"""Framework-internal Exception class (for internal use only)."""
911

1012
pass
1113

1214

15+
class EntityHandler(Callable):
16+
"""Durable Entity Handler.
17+
18+
A callable class that wraps the user defined entity function for execution by the Python worker
19+
and also allows access to the original method for unit testing
20+
"""
21+
22+
def __init__(self, func: Callable[[DurableEntityContext], None]):
23+
"""
24+
Create a new entity handler for the user defined entity function.
25+
26+
Parameters
27+
----------
28+
func: Callable[[DurableEntityContext], None]
29+
The user defined entity function.
30+
"""
31+
self.entity_function = func
32+
33+
def __call__(self, context: func.EntityContext) -> str:
34+
"""
35+
Handle the execution of the user defined entity function.
36+
37+
Parameters
38+
----------
39+
context : func.EntityContext
40+
The DF entity context
41+
"""
42+
# It is not clear when the context JSON would be found
43+
# inside a "body"-key, but this pattern matches the
44+
# orchestrator implementation, so we keep it for safety.
45+
context_body = getattr(context, "body", None)
46+
if context_body is None:
47+
context_body = context
48+
ctx, batch = DurableEntityContext.from_json(context_body)
49+
return Entity(self.entity_function).handle(ctx, batch)
50+
51+
1352
class Entity:
1453
"""Durable Entity Class.
1554
@@ -92,19 +131,10 @@ def create(cls, fn: Callable[[DurableEntityContext], None]) -> Callable[[Any], s
92131
93132
Returns
94133
-------
95-
Callable[[Any], str]
96-
Handle function of the newly created entity client
134+
EntityHandler
135+
Entity Handler callable for the newly created entity client
97136
"""
98-
def handle(context) -> str:
99-
# It is not clear when the context JSON would be found
100-
# inside a "body"-key, but this pattern matches the
101-
# orchestrator implementation, so we keep it for safety.
102-
context_body = getattr(context, "body", None)
103-
if context_body is None:
104-
context_body = context
105-
ctx, batch = DurableEntityContext.from_json(context_body)
106-
return Entity(fn).handle(ctx, batch)
107-
return handle
137+
return EntityHandler(fn)
108138

109139
def _elapsed_milliseconds_since(self, start_time: datetime) -> int:
110140
"""Calculate the elapsed time, in milliseconds, from the start_time to the present.

azure/durable_functions/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .DurableHttpRequest import DurableHttpRequest
1010
from .TokenSource import ManagedIdentityTokenSource
1111
from .DurableEntityContext import DurableEntityContext
12+
from .Task import TaskBase
1213

1314
__all__ = [
1415
'DurableOrchestrationBindings',
@@ -20,5 +21,6 @@
2021
'OrchestratorState',
2122
'OrchestrationRuntimeStatus',
2223
'PurgeHistoryResult',
23-
'RetryOptions'
24+
'RetryOptions',
25+
'TaskBase'
2426
]

azure/durable_functions/orchestrator.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,41 @@
1111
import azure.functions as func
1212

1313

14+
class OrchestrationHandler(Callable):
15+
"""Durable Orchestration Handler.
16+
17+
A callable class that wraps the user defined generator function for execution
18+
by the Python worker and also allows access to the original method for unit testing
19+
"""
20+
21+
def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]):
22+
"""
23+
Create a new orchestrator handler for the user defined orchestrator function.
24+
25+
Parameters
26+
----------
27+
func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]
28+
The user defined orchestrator function.
29+
"""
30+
self.orchestrator_function = func
31+
32+
def __call__(self, context: func.OrchestrationContext) -> str:
33+
"""
34+
Handle the execution of the user defined orchestrator function.
35+
36+
Parameters
37+
----------
38+
context : func.OrchestrationContext
39+
The DF orchestration context
40+
"""
41+
context_body = getattr(context, "body", None)
42+
if context_body is None:
43+
context_body = context
44+
return Orchestrator(self.orchestrator_function).handle(
45+
DurableOrchestrationContext.from_json(context_body)
46+
)
47+
48+
1449
class Orchestrator:
1550
"""Durable Orchestration Class.
1651
@@ -58,14 +93,7 @@ def create(cls, fn: Callable[[DurableOrchestrationContext], Generator[Any, Any,
5893
5994
Returns
6095
-------
61-
Callable[[Any], str]
62-
Handle function of the newly created orchestration client
96+
OrchestrationHandler
97+
Orchestration handler callable class for the newly created orchestration client
6398
"""
64-
65-
def handle(context: func.OrchestrationContext) -> str:
66-
context_body = getattr(context, "body", None)
67-
if context_body is None:
68-
context_body = context
69-
return Orchestrator(fn).handle(DurableOrchestrationContext.from_json(context_body))
70-
71-
return handle
99+
return OrchestrationHandler(fn)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Generator, Any, Union
2+
3+
from azure.durable_functions.models import TaskBase
4+
5+
6+
def orchestrator_generator_wrapper(
7+
generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]:
8+
"""Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic.
9+
10+
Parameters
11+
----------
12+
generator: Generator[TaskBase, Any, Any]
13+
Generator orchestrator as defined in the user function app. This generator is expected
14+
to yield a series of TaskBase objects and receive the results of these tasks until
15+
returning the result of the orchestrator.
16+
17+
Returns
18+
-------
19+
Generator[Union[TaskBase, Any], None, None]
20+
A simplified version of the orchestrator which takes no inputs. This generator will
21+
yield back the TaskBase objects that are yielded from the user orchestrator as well
22+
as the final result of the orchestrator. Exception handling is also simulated here
23+
in the same way as replay, where tasks returning exceptions are thrown back into the
24+
orchestrator.
25+
"""
26+
previous = next(generator)
27+
yield previous
28+
while True:
29+
try:
30+
previous_result = None
31+
try:
32+
previous_result = previous.result
33+
except Exception as e:
34+
# Simulated activity exceptions, timer interrupted exceptions,
35+
# or anytime a task would throw.
36+
previous = generator.throw(e)
37+
else:
38+
previous = generator.send(previous_result)
39+
yield previous
40+
except StopIteration as e:
41+
yield e.value
42+
return
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Unit testing utilities for Azure Durable functions."""
2+
from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper
3+
4+
__all__ = [
5+
'orchestrator_generator_wrapper'
6+
]

samples-v2/blueprint/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
# Manually managing azure-functions-worker may cause unexpected issues
44

55
azure-functions
6-
azure-functions-durable>=1.2.4
6+
azure-functions-durable>=1.2.4
7+
pytest

samples-v2/blueprint/tests/readme.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Durable Functions Sample – Unit Tests (Python)
2+
3+
## Overview
4+
5+
This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks.
6+
7+
Writing unit tests for Durable functions requires sligtly different syntax for accessing the original method definition. Orchestrator functions, client functions, and entity functions all come with their own ways to access the user code:
8+
9+
### Orchestrator functions
10+
```
11+
my_orchestrator.build().get_user_function().orchestrator_function
12+
```
13+
14+
### Client functions
15+
```
16+
my_client_function.build().get_user_function().client_function
17+
```
18+
19+
### Entity functions
20+
```
21+
my_entity_function.build().get_user_function().entity_function
22+
```
23+
24+
This sample app demonstrates using these accessors to get and test Durable functions. It also demonstrates how to mock the calling behavior that Durable uses to run orchestrators during replay with the orchestrator_generator_wrapper method defined in test_my_orchestrator.py and simulates the Tasks yielded by DurableOrchestrationContext with MockTask objects in the same file.
25+
26+
## Prerequisites
27+
28+
- Python
29+
- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (for running functions locally)
30+
- [pytest](https://docs.pytest.org) for test execution
31+
- VS Code with the **Python** and **Azure Functions** extensions (optional but recommended)
32+
33+
---
34+
35+
## Running Tests from the Command Line
36+
37+
1. Open a terminal or command prompt.
38+
2. Navigate to the project root (where your `requirements.txt` is).
39+
3. Create and activate a virtual environment:
40+
41+
```bash
42+
python -m venv .venv
43+
.venv\Scripts\activate # On Windows
44+
source .venv/bin/activate # On macOS/Linux
45+
```
46+
Install dependencies:
47+
48+
```bash
49+
pip install -r requirements.txt
50+
```
51+
52+
Run tests:
53+
54+
```bash
55+
pytest
56+
```
57+
58+
## Running Tests in Visual Studio Code
59+
1. Open the project folder in VS Code.
60+
2. Make sure the Python extension is installed.
61+
3. Open the Command Palette (Ctrl+Shift+P), then select:
62+
```
63+
Python: Configure Tests
64+
```
65+
4. Choose pytest as the test framework.
66+
5. Point to the tests/ folder when prompted.
67+
6. Once configured, run tests from the Test Explorer panel or inline with the test code.
68+
69+
Notes
70+
- Tests use mocks to simulate Durable Functions' context objects.
71+
- These are unit tests only; no real Azure services are called.
72+
- For integration tests, consider starting the host with func start.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import unittest
2+
from unittest.mock import Mock, call, patch
3+
from azure.durable_functions.testing import orchestrator_generator_wrapper
4+
5+
from durable_blueprints import my_orchestrator
6+
7+
@patch('azure.durable_functions.models.TaskBase')
8+
def mock_activity(activity_name, input, task):
9+
if activity_name == "say_hello":
10+
task.result = f"Hello {input}!"
11+
return task
12+
raise Exception("Activity not found")
13+
14+
15+
class TestFunction(unittest.TestCase):
16+
@patch('azure.durable_functions.DurableOrchestrationContext')
17+
def test_my_orchestrator(self, context):
18+
# Get the original method definition as seen in the function_app.py file
19+
func_call = my_orchestrator.build().get_user_function().orchestrator_function
20+
21+
context.call_activity = Mock(side_effect=mock_activity)
22+
# Create a generator using the method and mocked context
23+
user_orchestrator = func_call(context)
24+
25+
# Use orchestrator_generator_wrapper to get the values from the generator.
26+
# Processes the orchestrator in a way that is equivalent to the Durable replay logic
27+
values = [val for val in orchestrator_generator_wrapper(user_orchestrator)]
28+
29+
expected_activity_calls = [call('say_hello', 'Tokyo'),
30+
call('say_hello', 'Seattle'),
31+
call('say_hello', 'London')]
32+
33+
self.assertEqual(context.call_activity.call_count, 3)
34+
self.assertEqual(context.call_activity.call_args_list, expected_activity_calls)
35+
self.assertEqual(values[3], ["Hello Tokyo!", "Hello Seattle!", "Hello London!"])
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Activity functions require no special implementation aside from standard Azure Functions
2+
# unit testing for Python. As such, no test is implemented here.
3+
# For more information about testing Azure Functions in Python, see the official documentation:
4+
# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing

0 commit comments

Comments
 (0)