diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a543f6f..c1e81e77 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -31,4 +31,30 @@ jobs: flake8 . --count --show-source --statistics - name: Run tests run: | - pytest \ No newline at end of file + 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 diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 43f54bc0..62b5b704 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -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 diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index c025085d..2853e159 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -3,6 +3,8 @@ 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).""" @@ -10,6 +12,43 @@ class InternalEntityException(Exception): 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. @@ -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. diff --git a/azure/durable_functions/models/__init__.py b/azure/durable_functions/models/__init__.py index a61511d2..7737e9ae 100644 --- a/azure/durable_functions/models/__init__.py +++ b/azure/durable_functions/models/__init__.py @@ -9,6 +9,7 @@ from .DurableHttpRequest import DurableHttpRequest from .TokenSource import ManagedIdentityTokenSource from .DurableEntityContext import DurableEntityContext +from .Task import TaskBase __all__ = [ 'DurableOrchestrationBindings', @@ -20,5 +21,6 @@ 'OrchestratorState', 'OrchestrationRuntimeStatus', 'PurgeHistoryResult', - 'RetryOptions' + 'RetryOptions', + 'TaskBase' ] diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 085f59d9..9f9a9bf7 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -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. @@ -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) diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py new file mode 100644 index 00000000..f14cedb7 --- /dev/null +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -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 diff --git a/azure/durable_functions/testing/__init__.py b/azure/durable_functions/testing/__init__.py new file mode 100644 index 00000000..19a21681 --- /dev/null +++ b/azure/durable_functions/testing/__init__.py @@ -0,0 +1,6 @@ +"""Unit testing utilities for Azure Durable functions.""" +from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper + +__all__ = [ + 'orchestrator_generator_wrapper' +] diff --git a/samples-v2/blueprint/requirements.txt b/samples-v2/blueprint/requirements.txt index e1734eda..872c29ca 100644 --- a/samples-v2/blueprint/requirements.txt +++ b/samples-v2/blueprint/requirements.txt @@ -3,4 +3,5 @@ # Manually managing azure-functions-worker may cause unexpected issues azure-functions -azure-functions-durable>=1.2.4 \ No newline at end of file +azure-functions-durable>=1.2.4 +pytest \ No newline at end of file diff --git a/samples-v2/blueprint/tests/readme.md b/samples-v2/blueprint/tests/readme.md new file mode 100644 index 00000000..b483b523 --- /dev/null +++ b/samples-v2/blueprint/tests/readme.md @@ -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. \ No newline at end of file diff --git a/samples-v2/blueprint/tests/test_my_orchestrator.py b/samples-v2/blueprint/tests/test_my_orchestrator.py new file mode 100644 index 00000000..f9893261 --- /dev/null +++ b/samples-v2/blueprint/tests/test_my_orchestrator.py @@ -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!"]) diff --git a/samples-v2/blueprint/tests/test_say_hello.py b/samples-v2/blueprint/tests/test_say_hello.py new file mode 100644 index 00000000..59dc528c --- /dev/null +++ b/samples-v2/blueprint/tests/test_say_hello.py @@ -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 \ No newline at end of file diff --git a/samples-v2/blueprint/tests/test_start_orchestrator.py b/samples-v2/blueprint/tests/test_start_orchestrator.py new file mode 100644 index 00000000..0f357b56 --- /dev/null +++ b/samples-v2/blueprint/tests/test_start_orchestrator.py @@ -0,0 +1,26 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from durable_blueprints import start_orchestrator + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + func_call = start_orchestrator.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function') + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Execute the function code + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("my_orchestrator") + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") diff --git a/samples-v2/fan_in_fan_out/requirements.txt b/samples-v2/fan_in_fan_out/requirements.txt index 1b13a440..f067980f 100644 --- a/samples-v2/fan_in_fan_out/requirements.txt +++ b/samples-v2/fan_in_fan_out/requirements.txt @@ -4,4 +4,5 @@ azure-functions azure-functions-durable -azure-storage-blob \ No newline at end of file +azure-storage-blob +pytest \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/readme.md b/samples-v2/fan_in_fan_out/tests/readme.md new file mode 100644 index 00000000..c22ea8fc --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/readme.md @@ -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_E2_BackupSiteContent.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. \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py new file mode 100644 index 00000000..8adc29ba --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -0,0 +1,52 @@ +import unittest +from unittest.mock import Mock, call, patch +from azure.durable_functions.testing import orchestrator_generator_wrapper + +from function_app import E2_BackupSiteContent + + +@patch('azure.durable_functions.models.TaskBase') +def create_mock_task(result, task): + task.result = result + return task + + +def mock_activity(activity_name, input): + if activity_name == "E2_GetFileList": + return create_mock_task(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) + elif activity_name == "E2_CopyFileToBlob": + return create_mock_task(1) + raise Exception("Activity not found") + + +def mock_task_all(tasks): + return create_mock_task([t.result for t in tasks]) + + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationContext') + def test_E2_BackupSiteContent(self, context): + # Get the original method definition as seen in the function_app.py file + func_call = E2_BackupSiteContent.build().get_user_function().orchestrator_function + + context.get_input = Mock(return_value="C:/test") + context.call_activity = Mock(side_effect=mock_activity) + context.task_all = Mock(side_effect=mock_task_all) + + # Execute the function code + 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('E2_GetFileList', 'C:/test'), + call('E2_CopyFileToBlob', 'C:/test/E2_Activity.py'), + call('E2_CopyFileToBlob', 'C:/test/E2_Orchestrator.py')] + + self.assertEqual(context.call_activity.call_count, 3) + self.assertEqual(context.call_activity.call_args_list, expected_activity_calls) + + context.task_all.assert_called_once() + # Sums the result of task_all + self.assertEqual(values[2], 2) diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py new file mode 100644 index 00000000..59dc528c --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py @@ -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 \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py new file mode 100644 index 00000000..59dc528c --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py @@ -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 \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py new file mode 100644 index 00000000..08002f86 --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -0,0 +1,27 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from function_app import HttpStart + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + func_call = HttpStart.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function', + route_params={"functionName": "E2_BackupSiteContent"}) + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Execute the function code + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") diff --git a/samples-v2/function_chaining/requirements.txt b/samples-v2/function_chaining/requirements.txt index 58ba02bf..d2fabc19 100644 --- a/samples-v2/function_chaining/requirements.txt +++ b/samples-v2/function_chaining/requirements.txt @@ -4,3 +4,4 @@ azure-functions azure-functions-durable +pytest diff --git a/samples-v2/function_chaining/tests/readme.md b/samples-v2/function_chaining/tests/readme.md new file mode 100644 index 00000000..b483b523 --- /dev/null +++ b/samples-v2/function_chaining/tests/readme.md @@ -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. \ No newline at end of file diff --git a/samples-v2/function_chaining/tests/test_http_start.py b/samples-v2/function_chaining/tests/test_http_start.py new file mode 100644 index 00000000..6aa54c7b --- /dev/null +++ b/samples-v2/function_chaining/tests/test_http_start.py @@ -0,0 +1,27 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from function_app import http_start + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + func_call = http_start.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function', + route_params={"functionName": "my_orchestrator"}) + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Execute the function code + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("my_orchestrator") + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") diff --git a/samples-v2/function_chaining/tests/test_my_orchestrator.py b/samples-v2/function_chaining/tests/test_my_orchestrator.py new file mode 100644 index 00000000..a1b5efe6 --- /dev/null +++ b/samples-v2/function_chaining/tests/test_my_orchestrator.py @@ -0,0 +1,37 @@ +import unittest +from unittest.mock import Mock, call, patch +from azure.durable_functions.testing import orchestrator_generator_wrapper + +from function_app 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_chaining_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!"]) diff --git a/samples-v2/function_chaining/tests/test_say_hello.py b/samples-v2/function_chaining/tests/test_say_hello.py new file mode 100644 index 00000000..59dc528c --- /dev/null +++ b/samples-v2/function_chaining/tests/test_say_hello.py @@ -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 \ No newline at end of file