Skip to content

feat: expression mock api #161

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 15 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@
"ghcr.io/devcontainers-contrib/features/pre-commit:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteEnv": {
// -------------------------------------------------
// ENV settings to preserve history between rebuilds
"PROMPT_COMMAND": "history -a",
"HISTFILE": "/.devcontainercache/.bash_history",
// -------------------------------------------------
// CACHE Folders
"PRE_COMMIT_HOME": "/.devcontainercache/pre-commit",
"PIP_CACHE_DIR": "/.devcontainercache/pip-cache"
},
"mounts": [
"source=${devcontainerId},target=/.devcontainercache,type=volume"
],

// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
Expand All @@ -34,7 +47,8 @@
"GitHub.copilot",
"ms-python.python",
"ms-dotnettools.csharp",
"ryanluker.vscode-coverage-gutters"
"ryanluker.vscode-coverage-gutters",
"charliermarsh.ruff"
]
}
}
Expand Down
2 changes: 2 additions & 0 deletions .devcontainer/setup.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env bash

sudo chown -R $(id -u):$(id -g) /.devcontainercache

dotnet build
poetry install --no-interaction --no-root
poetry run pip install -e .
Expand Down
89 changes: 85 additions & 4 deletions docs/advanced/overriding_expression_functions.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,90 @@
# Overriding expression functions

The framework interprets expressions containing functions, which are implemented within the framework, and they might contain bugs.
You can override their implementation as illustrated below:
The framework supports mocking non-deterministic expression functions, such as as `rand` or `utcnow` by
providing mock results for them. This can be useful for non-deterministic functions or for testing purposes.

## Providing mock results for Expression Functions

You can provide mock results in two ways:

1. **Using the `data_factory_testing_framework.mock_helpers` module**: This provides the recommended and most convenient way to mock functions by passing either a single value or a list of values that will be returned in sequence.
2. **Using the `Mock` class directly**: This allows to create a mock object directly and set its return value.

When providing mock results, you can specify a **single value** or a **list of values**. If you provide a list, the mock will return the values in sequence for each call to the function similar to how `unittest.mock` works.

### Single Value Mocking

Assume a simple data pipeline that uses the `utcnow` function to set a variable using the SetVariable activity.

The example can be found in the `examples/fabric/mock_example` folder.

```python
FunctionsRepository.register("concat", lambda arguments: "".join(arguments))
FunctionsRepository.register("trim", lambda text, trim_argument: text.strip(trim_argument[0]))

# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Mock the utcnow function to return a fixed datetime:
utcnow_mock = mock_utcnow("1990-05-12T10:30:00Z")

# Act
activities = test_framework.evaluate_pipeline(
pipeline=pipeline,
parameters=[]
mocks=[utcnow_mock]
)

# Assert
activity = next(activities)
assert activity.type_properties["value"].result == ["1990-05-12T10:30:00Z", "1990-05-12T10:30:00Z"]
```

### List of Values Mocking

You can also provide a list of values to be returned in sequence. This is useful when you want to simulate different results for each call to the function.

```python
def test_mock_utcnow_example(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Create a global mock for utcnow
utcnow_mock = mock_utcnow(
[
"1980-05-12T10:30:00Z",
"1990-05-12T10:30:00Z",
]
)

# Act
activities = test_framework.evaluate_pipeline(
pipeline,
parameters=[],
mocks=[utcnow_mock],
)

# Assert
activity = next(activities)
assert activity.type_properties["value"].result == ["1980-05-12T10:30:00Z", "1990-05-12T10:30:00Z"]

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)
```

If the values are exhausted an error will be raised. This is similar to how `unittest.mock` works.


## Scope of Mocked Functions

By default, the mocked result apply to all pipelines, activties, and type properties in the current test run (i.e., when the the framework evaluates the pipline or activity).
You can also specify a scope which allows you to limit the mocked result to a specific pipeline, activity, or type property to test more specific scenarios.

```python

# TODO: Add example code for mocking expression functions

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "DataPipeline",
"displayName": "AdditionalExamplePipeline"
},
"config": {
"version": "2.0",
"logicalId": "c7986cc7-a6df-45fc-b4b6-dfc3cbddf2a6"
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "DataPipeline",
"displayName": "ExamplePipeline"
},
"config": {
"version": "2.0",
"logicalId": "c7986cc7-a6df-45fc-b4b6-dfc3cbddf2a6"
}
}
Binary file not shown.
106 changes: 106 additions & 0 deletions examples/fabric/mock_example/test_fabric_mock_utcnow_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from pathlib import Path

import pytest
from data_factory_testing_framework import TestFramework, TestFrameworkType
from data_factory_testing_framework.mock_helpers import mock_utcnow


def test_mock_utcnow_single_value(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Create a global mock for utcnow
utcnow_mock = mock_utcnow("1990-05-12T10:30:00Z")

# Act
activities = test_framework.evaluate_pipeline(
pipeline,
parameters=[],
mocks=[utcnow_mock],
)

# Assert
activity = next(activities)

assert activity.type_properties["value"].result == ["1990-05-12T10:30:00Z", "1990-05-12T10:30:00Z"]

activity = next(activities)
# note: we ignore the assertions here for the purpose of this example

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)

def test_mock_utcnow_multiple_values(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Create a global mock for utcnow
utcnow_mock = mock_utcnow(
[
"1980-05-12T10:30:00Z",
"1990-05-12T10:30:00Z",
]
)

# Act
activities = test_framework.evaluate_pipeline(
pipeline,
parameters=[],
mocks=[utcnow_mock],
)

# Assert
activity = next(activities)
assert activity.type_properties["value"].result == ["1980-05-12T10:30:00Z", "1990-05-12T10:30:00Z"]

activity = next(activities)
# note: we ignore the assertions here for the purpose of this example

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)

def test_mock_utcnow_with_activity_scope(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Create a mock for utcnow with activity scope
utcnow_mock = mock_utcnow("1980-05-12T10:30:00Z", activity="Set Input Data")
utcnow_mock_set_addtional_input = mock_utcnow("1990-05-12T10:30:00Z", activity="Set Additional Input Data")

# Act
activities = test_framework.evaluate_pipeline(
pipeline,
parameters=[],
mocks=[
utcnow_mock,
utcnow_mock_set_addtional_input
],
)

# Assert
activity = next(activities)
assert activity.type_properties["value"].result == ["1980-05-12T10:30:00Z", "1980-05-12T10:30:00Z"]

activity = next(activities)
assert activity.type_properties["value"].result == ["1990-05-12T10:30:00Z", "1990-05-12T10:30:00Z"]

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)

def test_mock_utcnow_with_scope_different_pipeline(request: pytest.FixtureRequest) -> None:

# TODO: create a nested pipeline to test the scope functionality
# Need a nested pipeline to test the scope functionality

pass


Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path

import pytest
from data_factory_testing_framework import TestFramework, TestFrameworkType
from data_factory_testing_framework.mock_helpers import mock_utcnow


def test_simple_web_hook_with_utcnow_mock(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(framework_type=TestFrameworkType.Fabric, root_folder_path=fabric_folder)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Add a global utcnow mock
utcnow_mock = mock_utcnow("2025-05-12T10:30:00Z")

# Act
activities = test_framework.evaluate_pipeline(pipeline, [], mocks=[utcnow_mock])

# Assert
activity = next(activities)
assert activity.name == "Set Input Data"

activity = next(activities)
assert activity.name == "Call Webhook"

activity = next(activities)
assert activity.name == "Call Webhook"

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)
37 changes: 37 additions & 0 deletions examples/fabric/simple_web_hook/test_mock_utcnow_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pytest
from data_factory_testing_framework import TestFramework, TestFrameworkType
from data_factory_testing_framework.mock_helpers import mock_utcnow
from pathlib import Path

def test_mock_utcnow_example(request: pytest.FixtureRequest) -> None:
# Arrange
fabric_folder = Path(request.fspath.dirname, "fabric")
test_framework = TestFramework(
framework_type=TestFrameworkType.Fabric,
root_folder_path=fabric_folder
)
pipeline = test_framework.get_pipeline_by_name("ExamplePipeline")

# Create a global mock for utcnow
utcnow_mock = mock_utcnow("2025-05-12T10:30:00Z")

# Act
activities = test_framework.evaluate_pipeline(
pipeline,
parameters=[],
mocks=[utcnow_mock],
)

# Assert
activity = next(activities)
assert activity.name == "Set Input Data"

activity = next(activities)
assert activity.name == "Call Webhook"

activity = next(activities)
assert activity.name == "Call Webhook"

# Assert that there are no more activities
with pytest.raises(StopIteration):
next(activities)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
from typing import List

from data_factory_testing_framework._expression_runtime.data_factory_expression.expression_transformer import (
ExpressionTransformer as DataFactoryTestingFrameworkExpressionsTransformer,
Expand All @@ -12,6 +13,8 @@
StateIterationItemNotSetError,
VariableNotFoundError,
)
from data_factory_testing_framework.mock import ExpressionMock
from data_factory_testing_framework.mock_context import MockContext
from data_factory_testing_framework.state import PipelineRunState, RunParameterType


Expand All @@ -21,12 +24,41 @@ def __init__(self) -> None:
self.dftf_expressions_transformer = DataFactoryTestingFrameworkExpressionsTransformer()
self.dftf_expressions_evaluator = DataFactoryTestingFrameworkExpressionsEvaluator()

def evaluate(self, expression: str, state: PipelineRunState) -> str:
def _build_mock_config(
self,
mocks: List[ExpressionMock],
mock_context: MockContext,
) -> dict:
"""Builds a mock configuration dictionary from the provided mocks and context."""
mock_config = {}

for mock in mocks:
if mock.scope.is_in_scope(mock_context):
# If we already have a mock for this function, we throw an error
if mock.function_name in mock_config:
raise ValueError(
f'Duplicate mock function name detected: "{mock.function_name}". '
"Please ensure mocks have mutually exclusive scopes."
)
else:
mock_config[mock.function_name] = mock.mock_result

return mock_config

def evaluate(
self,
expression: str,
state: PipelineRunState,
mocks: List[ExpressionMock],
mock_context: MockContext,
) -> str:
"""Evaluate an expression with optional mocks and context."""
mock_config = self._build_mock_config(mocks, mock_context)
dftf_transformed_expression = self.dftf_expressions_transformer.transform_to_dftf_evaluator_expression(
expression, state
)
try:
result = self.dftf_expressions_evaluator.evaluate(dftf_transformed_expression, state)
result = self.dftf_expressions_evaluator.evaluate(dftf_transformed_expression, state, mock_config)
except Exception as e:
# match the exception type (coming from .NET) to the one we expect
missing_parameter_match = re.match(
Expand Down
Loading
Loading