Skip to content

Add change supporting unit testing #537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0e33ce3
Add change supporting unit testing
andystaples Apr 3, 2025
90500a2
Add support for durable client functions
andystaples Apr 3, 2025
ed470ce
Naming
andystaples Apr 18, 2025
69bd129
Add test samples to fan_in_fan_out app
andystaples Apr 23, 2025
a1282eb
Linting fixes
andystaples Apr 23, 2025
5adfb63
Merge branch 'dev' into andystaples/add-unit-testing-change
andystaples Apr 23, 2025
11cad72
Linting fixes 2
andystaples Apr 23, 2025
6d8c330
Merge branch 'andystaples/add-unit-testing-change' of https://github.…
andystaples Apr 23, 2025
2ef009f
Linter fixes 3
andystaples Apr 23, 2025
4584c90
Probable test issue fix
andystaples Apr 29, 2025
8f09b5a
Exclude samples from pytest github workflow
andystaples Apr 29, 2025
7902b65
Add testing matrix for samples
andystaples May 2, 2025
d2f8d1e
Pipeline fix
andystaples May 2, 2025
55886db
Build extension into tests
andystaples May 2, 2025
a2ca265
Merge branch 'dev' into andystaples/add-unit-testing-change
andystaples May 2, 2025
a9dd0e0
Add tests to other projects
andystaples May 2, 2025
7e10d5e
Tweak script
andystaples May 2, 2025
545fbcc
Update tests from PR feedback
andystaples May 5, 2025
c5f3540
Fix tests
andystaples May 5, 2025
f2db1cd
Fix tests
andystaples May 5, 2025
3a2f94b
Fix tests
andystaples May 5, 2025
0cb7871
PR feedback
andystaples May 7, 2025
81b67f5
Expose OrchestratorGeneratorWrapper in SDK (#548)
andystaples May 13, 2025
02ab139
Linting fixes
andystaples May 13, 2025
739a9e8
Test fix
andystaples May 13, 2025
b489e05
Linting fix
andystaples May 13, 2025
2343381
More linting and test fixes
andystaples May 13, 2025
850b2d0
Linting again
andystaples May 13, 2025
9ab1c17
Delete azure-functions-durable-python.sln
andystaples May 22, 2025
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
28 changes: 27 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,30 @@ jobs:
flake8 . --count --show-source --statistics
- name: Run tests
run: |
pytest
pytest --ignore=samples-v2

test-samples:
strategy:
matrix:
app_name: [blueprint, fan_in_fan_out, function_chaining]
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./samples-v2/${{ matrix.app_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r ../../requirements.txt
pip install ../.. --no-cache-dir --upgrade --no-deps --force-reinstall
- name: Run tests
run: |
python -m pytest
11 changes: 11 additions & 0 deletions azure/durable_functions/decorators/durable_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ async def df_client_middleware(*args, **kwargs):
# Invoke user code with rich DF Client binding
return await user_code(*args, **kwargs)

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

user_code_with_rich_client = df_client_middleware
fb._function._func = user_code_with_rich_client

Expand Down
54 changes: 42 additions & 12 deletions azure/durable_functions/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,52 @@
from datetime import datetime
from typing import Callable, Any, List, Dict

import azure.functions as func


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

pass


class EntityHandler(Callable):
"""Durable Entity Handler.

A callable class that wraps the user defined entity function for execution by the Python worker
and also allows access to the original method for unit testing
"""

def __init__(self, func: Callable[[DurableEntityContext], None]):
"""
Create a new entity handler for the user defined entity function.

Parameters
----------
func: Callable[[DurableEntityContext], None]
The user defined entity function.
"""
self.entity_function = func

def __call__(self, context: func.EntityContext) -> str:
"""
Handle the execution of the user defined entity function.

Parameters
----------
context : func.EntityContext
The DF entity context
"""
# It is not clear when the context JSON would be found
# inside a "body"-key, but this pattern matches the
# orchestrator implementation, so we keep it for safety.
context_body = getattr(context, "body", None)
if context_body is None:
context_body = context
ctx, batch = DurableEntityContext.from_json(context_body)
return Entity(self.entity_function).handle(ctx, batch)


class Entity:
"""Durable Entity Class.

Expand Down Expand Up @@ -92,19 +131,10 @@ def create(cls, fn: Callable[[DurableEntityContext], None]) -> Callable[[Any], s

Returns
-------
Callable[[Any], str]
Handle function of the newly created entity client
EntityHandler
Entity Handler callable for the newly created entity client
"""
def handle(context) -> str:
# It is not clear when the context JSON would be found
# inside a "body"-key, but this pattern matches the
# orchestrator implementation, so we keep it for safety.
context_body = getattr(context, "body", None)
if context_body is None:
context_body = context
ctx, batch = DurableEntityContext.from_json(context_body)
return Entity(fn).handle(ctx, batch)
return handle
return EntityHandler(fn)

def _elapsed_milliseconds_since(self, start_time: datetime) -> int:
"""Calculate the elapsed time, in milliseconds, from the start_time to the present.
Expand Down
4 changes: 3 additions & 1 deletion azure/durable_functions/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .DurableHttpRequest import DurableHttpRequest
from .TokenSource import ManagedIdentityTokenSource
from .DurableEntityContext import DurableEntityContext
from .Task import TaskBase

__all__ = [
'DurableOrchestrationBindings',
Expand All @@ -20,5 +21,6 @@
'OrchestratorState',
'OrchestrationRuntimeStatus',
'PurgeHistoryResult',
'RetryOptions'
'RetryOptions',
'TaskBase'
]
48 changes: 38 additions & 10 deletions azure/durable_functions/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@
import azure.functions as func


class OrchestrationHandler(Callable):
"""Durable Orchestration Handler.

A callable class that wraps the user defined generator function for execution
by the Python worker and also allows access to the original method for unit testing
"""

def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]):
"""
Create a new orchestrator handler for the user defined orchestrator function.

Parameters
----------
func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]
The user defined orchestrator function.
"""
self.orchestrator_function = func

def __call__(self, context: func.OrchestrationContext) -> str:
"""
Handle the execution of the user defined orchestrator function.

Parameters
----------
context : func.OrchestrationContext
The DF orchestration context
"""
context_body = getattr(context, "body", None)
if context_body is None:
context_body = context
return Orchestrator(self.orchestrator_function).handle(
DurableOrchestrationContext.from_json(context_body)
)


class Orchestrator:
"""Durable Orchestration Class.

Expand Down Expand Up @@ -58,14 +93,7 @@ def create(cls, fn: Callable[[DurableOrchestrationContext], Generator[Any, Any,

Returns
-------
Callable[[Any], str]
Handle function of the newly created orchestration client
OrchestrationHandler
Orchestration handler callable class for the newly created orchestration client
"""

def handle(context: func.OrchestrationContext) -> str:
context_body = getattr(context, "body", None)
if context_body is None:
context_body = context
return Orchestrator(fn).handle(DurableOrchestrationContext.from_json(context_body))

return handle
return OrchestrationHandler(fn)
42 changes: 42 additions & 0 deletions azure/durable_functions/testing/OrchestratorGeneratorWrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import Generator, Any, Union

from azure.durable_functions.models import TaskBase


def orchestrator_generator_wrapper(
generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]:
"""Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic.

Parameters
----------
generator: Generator[TaskBase, Any, Any]
Generator orchestrator as defined in the user function app. This generator is expected
to yield a series of TaskBase objects and receive the results of these tasks until
returning the result of the orchestrator.

Returns
-------
Generator[Union[TaskBase, Any], None, None]
A simplified version of the orchestrator which takes no inputs. This generator will
yield back the TaskBase objects that are yielded from the user orchestrator as well
as the final result of the orchestrator. Exception handling is also simulated here
in the same way as replay, where tasks returning exceptions are thrown back into the
orchestrator.
"""
previous = next(generator)
yield previous
while True:
try:
previous_result = None
try:
previous_result = previous.result
except Exception as e:
# Simulated activity exceptions, timer interrupted exceptions,
# or anytime a task would throw.
previous = generator.throw(e)
else:
previous = generator.send(previous_result)
yield previous
except StopIteration as e:
yield e.value
return
6 changes: 6 additions & 0 deletions azure/durable_functions/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Unit testing utilities for Azure Durable functions."""
from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper

__all__ = [
'orchestrator_generator_wrapper'
]
3 changes: 2 additions & 1 deletion samples-v2/blueprint/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
# Manually managing azure-functions-worker may cause unexpected issues

azure-functions
azure-functions-durable>=1.2.4
azure-functions-durable>=1.2.4
pytest
72 changes: 72 additions & 0 deletions samples-v2/blueprint/tests/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Durable Functions Sample – Unit Tests (Python)

## Overview

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.

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:

### Orchestrator functions
```
my_orchestrator.build().get_user_function().orchestrator_function
```

### Client functions
```
my_client_function.build().get_user_function().client_function
```

### Entity functions
```
my_entity_function.build().get_user_function().entity_function
```

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.

## Prerequisites

- Python
- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (for running functions locally)
- [pytest](https://docs.pytest.org) for test execution
- VS Code with the **Python** and **Azure Functions** extensions (optional but recommended)

---

## Running Tests from the Command Line

1. Open a terminal or command prompt.
2. Navigate to the project root (where your `requirements.txt` is).
3. Create and activate a virtual environment:

```bash
python -m venv .venv
.venv\Scripts\activate # On Windows
source .venv/bin/activate # On macOS/Linux
```
Install dependencies:

```bash
pip install -r requirements.txt
```

Run tests:

```bash
pytest
```

## Running Tests in Visual Studio Code
1. Open the project folder in VS Code.
2. Make sure the Python extension is installed.
3. Open the Command Palette (Ctrl+Shift+P), then select:
```
Python: Configure Tests
```
4. Choose pytest as the test framework.
5. Point to the tests/ folder when prompted.
6. Once configured, run tests from the Test Explorer panel or inline with the test code.

Notes
- Tests use mocks to simulate Durable Functions' context objects.
- These are unit tests only; no real Azure services are called.
- For integration tests, consider starting the host with func start.
35 changes: 35 additions & 0 deletions samples-v2/blueprint/tests/test_my_orchestrator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import unittest
from unittest.mock import Mock, call, patch
from azure.durable_functions.testing import orchestrator_generator_wrapper

from durable_blueprints import my_orchestrator

@patch('azure.durable_functions.models.TaskBase')
def mock_activity(activity_name, input, task):
if activity_name == "say_hello":
task.result = f"Hello {input}!"
return task
raise Exception("Activity not found")


class TestFunction(unittest.TestCase):
@patch('azure.durable_functions.DurableOrchestrationContext')
def test_my_orchestrator(self, context):
# Get the original method definition as seen in the function_app.py file
func_call = my_orchestrator.build().get_user_function().orchestrator_function

context.call_activity = Mock(side_effect=mock_activity)
# Create a generator using the method and mocked context
user_orchestrator = func_call(context)

# Use orchestrator_generator_wrapper to get the values from the generator.
# Processes the orchestrator in a way that is equivalent to the Durable replay logic
values = [val for val in orchestrator_generator_wrapper(user_orchestrator)]

expected_activity_calls = [call('say_hello', 'Tokyo'),
call('say_hello', 'Seattle'),
call('say_hello', 'London')]

self.assertEqual(context.call_activity.call_count, 3)
self.assertEqual(context.call_activity.call_args_list, expected_activity_calls)
self.assertEqual(values[3], ["Hello Tokyo!", "Hello Seattle!", "Hello London!"])
4 changes: 4 additions & 0 deletions samples-v2/blueprint/tests/test_say_hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Activity functions require no special implementation aside from standard Azure Functions
# unit testing for Python. As such, no test is implemented here.
# For more information about testing Azure Functions in Python, see the official documentation:
# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing
Loading