-
Notifications
You must be signed in to change notification settings - Fork 56
Enterprise Unit Testing Sample #310
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
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,180 @@ | ||||||||
# Subscription Creation Workflow with Unit Testing | ||||||||
|
||||||||
This project demonstrates a durable workflow that manages a subscription creation long running lifecyle and is adapted from a canonical | ||||||||
real world example. | ||||||||
The durable orchestration, will create a subscription, wait for the subscription to be created (through the durable timer) | ||||||||
and update status of subscription creation in an in-memory status object. | ||||||||
|
||||||||
This also demonstrates usage of: | ||||||||
|
||||||||
- EasyAuth using decoraters | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tiny nit
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. This is a canonical customer user case so adapting it into a simpler form and keeping the balance between canonical use case vs simplicity is key. Will iterate till it reaches a shape we can agree on |
||||||||
- Serialization of custom classes | ||||||||
- Unit Test Methodology | ||||||||
|
||||||||
# Durable Orchestration Patterns Used | ||||||||
|
||||||||
- Fan In/Fan Out | ||||||||
- Sub Orchestrations | ||||||||
- Function Chaining | ||||||||
- Durable Monitor | ||||||||
|
||||||||
# Unit Testing Guide | ||||||||
|
||||||||
This example shows how we can unit test durable function patterns using python unittest patch and mock constructs and some noteworthy mocks. | ||||||||
|
||||||||
## Unit Testing Durable HTTP Start Invocation with decorators for EasyAuth | ||||||||
|
||||||||
The Durable HTTP Starter is invoked with a `X-MS-CLIENT-PRINCIPAL` in the header of the HTTP request. When configuring EasyAuth, the function needs to be validated against the claims presented. This validation is done via an authorize decorator in this sample. | ||||||||
|
||||||||
When making an HTTP request to the service (GET or PUT), you can use the following token to act as both a SubscriptionManager (PUT) and a SubscriptionReader (GET): | ||||||||
|
||||||||
`ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0=` | ||||||||
|
||||||||
For unit testing the decorator (that get's initialized with a specific environment variable) we just patch in the variable before importing the Durable HTTP Start method like this: | ||||||||
|
||||||||
```python | ||||||||
with patch.dict(os.environ={"SecurityGroups_SUBSCRIPTION_MANAGERS":"ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c"}): | ||||||||
from ..CreateEnvironmentHTTPStart import main | ||||||||
```` | ||||||||
|
||||||||
and make sure we are sending the right `X-MS-CLIENT-PRINCIPAL` in the header of the mocked HttpRequest as seen in [this](./subscription-manager/tests/test_createenvhttpstart_validauth.py) test. | ||||||||
|
||||||||
|
||||||||
We are patching the group-id that gets base64 decoded and compared with the above claims principal sent in the header of the http request | ||||||||
|
||||||||
Refer [Auth](./subscription-manager/Auth/authorization.py) for details on how this works | ||||||||
|
||||||||
--- | ||||||||
|
||||||||
## Unit Testing Orchestrator Function | ||||||||
|
||||||||
When mocking an orchestrator, the durable orchestration context is mocked like this: | ||||||||
|
||||||||
```python | ||||||||
with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: | ||||||||
mock.get_input = MagicMock(return_value={'displayName' : 'test'}) | ||||||||
mock.call_sub_orchestrator.side_effect = sub_orc_mock | ||||||||
mock.task_all.side_effect = task_all_mock | ||||||||
|
||||||||
# To get generator results do a next. If orchestrator response is a list, then wrap the function call around a list | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand the comment about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could use next(orchestrator_fn(mock)) as well here, is that more intuitive? using list just makes sure the entire computed result is back for inspection - no big value with list. Let me know if converting it into a next call is more explanatory. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I think I understand this now. This is similar to how In this case, I think it would be simpler if we continued using |
||||||||
result = list(orchestrator_fn(mock)) | ||||||||
self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result[0]) | ||||||||
``` | ||||||||
|
||||||||
MagicMock can be used to return a set of canned values, for eg: `get_input` expects a specific dictionary as shown above. | ||||||||
|
||||||||
For intercepting any method calls on the mock, we define a `side_effect` that is a local method. For eg: `task_all_mock` side effect checks the list of tasks that it received | ||||||||
|
||||||||
```python | ||||||||
def task_all_mock(tasks:list): | ||||||||
assert len(tasks) == 2 | ||||||||
return list | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we returning just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes changed it to [] (this is a mistake due to code churns - sorry!) The task_all never checked back the return type so it probably just returned the list Class and passed. |
||||||||
``` | ||||||||
|
||||||||
Here we check if we received two tasks and we can go further and use `assertIsInstance` to check what classes the tasks belong to etc. | ||||||||
|
||||||||
Finally we invoke the orchestrator as | ||||||||
|
||||||||
```python | ||||||||
result = list(orchestrator_fn(mock)) | ||||||||
``` | ||||||||
|
||||||||
and further inspect the result | ||||||||
|
||||||||
--- | ||||||||
|
||||||||
## Unit Testing Durable Monitor Pattern | ||||||||
|
||||||||
Here the durable monitor calls an activity function to get the status of a subscription creation process. Depending upon the status, it will schedule a durable timer to poll again or will proceed further in the orchestration. | ||||||||
|
||||||||
To simulate this in the unit test, we might want to send back the results of `call_activity` into the orchestrator and so the orchestrator is invoked in a specific way taking advantage of the generators. | ||||||||
|
||||||||
```python | ||||||||
gen_orchestrator = orchestrator_fn(mock) | ||||||||
try: | ||||||||
# Make a call to Status check to see and if response is accepted (subscription is in process of being created) | ||||||||
next(gen_orchestrator) | ||||||||
|
||||||||
# Send back response to orchestrator | ||||||||
gen_orchestrator.send(accepted_response) | ||||||||
|
||||||||
# Timer is set and now the call succeeds | ||||||||
next(gen_orchestrator) | ||||||||
|
||||||||
# Send back success response to orchestrator | ||||||||
gen_orchestrator.send(succeeded_response) | ||||||||
|
||||||||
except StopIteration as e: | ||||||||
result = e.value | ||||||||
self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result) | ||||||||
``` | ||||||||
|
||||||||
For more details refer [this test that simulates the durable timer calls](./subscription-manager/tests/test_createsubscription_suborchestrator.py). | ||||||||
|
||||||||
--- | ||||||||
|
||||||||
## Unit Testing Callbacks and patching environment variables | ||||||||
|
||||||||
If your activity function or orchestrator or any helper methods use environment variables internally, this code below demonstrates how to patch these environment variables in an isolated manner. | ||||||||
|
||||||||
```python | ||||||||
# Patch environment variables | ||||||||
patch_env_mock = mock.patch.dict(os.environ, {"RUNTIME_ENVIRONMENT": "LOCAL", | ||||||||
"STATUS_STORAGE" : "IN-MEMORY"}) | ||||||||
patch_env_mock.start() | ||||||||
|
||||||||
# Patch the update callback to intercept and inspect status | ||||||||
with patch("subscription-manager.StatusCheck.update_callback") as function_mock: | ||||||||
function_mock.side_effect = mock_callback | ||||||||
result = await main(payload) | ||||||||
self.assertIsInstance(result,SubscriptionResponse) | ||||||||
self.assertEqual(result.properties.subscriptionId,"1111111-2222-3333-4444-ebc1b75b9d74") | ||||||||
self.assertEqual(result.properties.provisioningState,"NotFound") | ||||||||
patch_env_mock.stop() | ||||||||
``` | ||||||||
|
||||||||
--- | ||||||||
## Unit testing internal Callback methods | ||||||||
|
||||||||
The subscription manager uses a custom callback that gets called from another method invoked | ||||||||
inside of an activity function. The following code demonstrates how to patch these callbacks: | ||||||||
Comment on lines
+139
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The first sentence here is a little complex. Any chance we could simplify it? I also don't think the concept of a subscription manager has been introduced until now, so it would be great to have at least one sentence explaining what that is, or linking to the code :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do. This was very specific to the customer use case in fact. Actually would want to discuss in general how to do a lambda based call back. |
||||||||
|
||||||||
### Assign a side-effect method that can intercept the call | ||||||||
|
||||||||
```python | ||||||||
# Patch the update callback to intercept and inspect status | ||||||||
with patch("subscription-manager.StatusCheck.update_callback") as function_mock: | ||||||||
function_mock.side_effect = mock_callback | ||||||||
``` | ||||||||
|
||||||||
### Call the actual callback within the side effect method | ||||||||
|
||||||||
```python | ||||||||
def mock_callback(status: Status): | ||||||||
updated_status = update_callback(status) | ||||||||
``` | ||||||||
|
||||||||
### Assert the response | ||||||||
|
||||||||
```python | ||||||||
def mock_callback(status: Status): | ||||||||
updated_status = update_callback(status) | ||||||||
|
||||||||
# NotFound is returned by LocalSubscription emulation and we expect the same to be set here | ||||||||
assert updated_status.creation_status == "NotFound" | ||||||||
``` | ||||||||
--- | ||||||||
|
||||||||
## Running Locally | ||||||||
|
||||||||
This example can be run locally the sample call, [test_orchestration.http](./test_orchestration.http) using the [REST Client for VS Code](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) | ||||||||
|
||||||||
--- | ||||||||
## Running all unit tests | ||||||||
|
||||||||
The script [run_unit_tests.sh](./run_unit_tests.sh) can be used to invoke all the tests with the right module paths wired in. | ||||||||
- Create a python virtual environment `python3 -m venv env` | ||||||||
- Activate it `source env/bin/activate` | ||||||||
- Run unit tests `sh run_unit_tests.sh` | ||||||||
|
||||||||
--- |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
#! /usr/bin/bash | ||
|
||
# Bash script to run all unit tests from tests folder. | ||
# Make sure the test name is of the format "test_*.py" | ||
TESTS="./subscription-manager/unit_tests" | ||
for TEST_NAME in $TESTS/* | ||
do | ||
# Remove non-tests | ||
if [[ $TEST_NAME = *"__init__"* || $TEST_NAME = *"pycache"* ]]; then | ||
continue | ||
fi | ||
echo "Running $TEST_NAME ..." | ||
|
||
# Cut out the directory names and trim .py extension | ||
SUFFIX_NAME=$(echo $TEST_NAME | cut -d "/" -f 4 | cut -d "." -f 1) | ||
python -m unittest subscription-manager.unit_tests.$SUFFIX_NAME | ||
done | ||
Comment on lines
+1
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally, we shouldn't need an |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
""" | ||
Demo Activity Function to add subscription to management group | ||
""" | ||
def main(name:str) -> str: | ||
return f"Added subscription to management group" | ||
Comment on lines
+1
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest adding a little clarification that explicitly states that this is "pretending" to add a subscription :) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
{ | ||
"scriptFile": "__init__.py", | ||
"bindings": [ | ||
{ | ||
"name": "name", | ||
"type": "activityTrigger", | ||
"direction": "in", | ||
"datatype": "string" | ||
} | ||
], | ||
"disabled": false | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import logging | ||
import azure.functions as func | ||
from .usertoken import UserToken | ||
from functools import wraps | ||
|
||
""" | ||
Decorator method that is called for authorization before the Durable HTTP start method is invoked. | ||
It uses the X-MS-CLIENT-PRINCIPAL id in the request header to authenticate against a set of known | ||
Subscription Managers/Readers | ||
""" | ||
def authorize(allowed_groups:list): | ||
"""Wrap the decorator to allow passing in a group name""" | ||
def decorator_authorize(decorated_function): | ||
"""Decorator to handle authorization""" | ||
|
||
# Wraps ensures that the decorated function's parameters are exposed and not | ||
# this function's parameters. | ||
@wraps(decorated_function) | ||
Comment on lines
+16
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding was that |
||
async def validate_authorization(*args, **kwargs): | ||
"""Check authorization of caller""" | ||
logging.info("In the authorization decorator authorizing for %s" %(allowed_groups)) | ||
|
||
# Get 'req' parameter that was passed to the decorated function | ||
request = kwargs['req'] | ||
|
||
# Get the claims token from the request header | ||
token_b64 = request.headers.get('X-MS-CLIENT-PRINCIPAL', '') | ||
|
||
# Simulate 403 call if we don't pass in a header | ||
if token_b64 == '': | ||
return func.HttpResponse("", status_code=403) | ||
user_token = UserToken(token_b64) | ||
|
||
for group_id in allowed_groups: | ||
if user_token.is_member(group_id): | ||
# Call the decorated function | ||
return await decorated_function(*args, **kwargs) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what do we expect the decorated function to do? It seems that we expect it to do something very specific? If so, can we rename it to have a more usage-specific name? |
||
else: | ||
return func.HttpResponse("", status_code=403) | ||
|
||
return validate_authorization | ||
return decorator_authorize |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import os | ||
|
||
""" | ||
Set of known groups represented as environment variables. | ||
Subscription Managers: Service principals have permissions to create and delete subscriptions | ||
Subscription Readers: Service principals have permissions to read subscription | ||
""" | ||
class SecurityGroups: | ||
subscription_managers = os.getenv("SecurityGroups_SUBSCRIPTION_MANAGERS") | ||
subscription_readers = os.getenv("SecurityGroups_SUBSCRIPTION_READERS") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import json,base64 | ||
|
||
""" | ||
Represents a UserToken with a specific set of user id's and groups. | ||
Validates token from Identifies if a token belongs to a specific group from the claims | ||
Comment on lines
+1
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sentence "Validates token from Identifies if a token belongs to a specific group from the claims" confuses me a little. Is "Identifies" a typo? |
||
""" | ||
class UserToken: | ||
id_token = [] | ||
group_ids = [] | ||
|
||
def __init__(self,base64_token:str): | ||
|
||
decoded_token = base64.b64decode(base64_token) | ||
id_token = json.loads(decoded_token) | ||
|
||
try: | ||
UserToken.validate_token(id_token) | ||
self.id_token = id_token | ||
self.group_ids = UserToken.get_group_ids(self.id_token) | ||
except Exception as e: | ||
raise | ||
|
||
@staticmethod | ||
def get_group_ids(id_token:str): | ||
claims = id_token["claims"] | ||
group_ids = [c["val"] for c in claims if c["typ"] == "groups"] | ||
return group_ids | ||
|
||
@staticmethod | ||
def validate_token(id_token:str): | ||
try: | ||
claims = id_token["claims"] | ||
except Exception as e: | ||
raise | ||
|
||
def is_member(self,group_id:str): | ||
if group_id in self.group_ids: | ||
return True | ||
return False | ||
Comment on lines
+36
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can simplify this to |
||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import logging, json | ||
import azure.durable_functions as df | ||
import azure.functions as func | ||
from ..Status.inmemorystatusmanager import InMemoryStatusManager | ||
from ..Status.status import Status | ||
from ..Auth import authorization | ||
from ..Auth.knowngroups import SecurityGroups | ||
|
||
""" | ||
Update callback that can be customized to make any changes to the status | ||
""" | ||
def update_callback(status: Status): | ||
return status | ||
|
||
""" | ||
Durable HTTP Start that kicks off orchestration for creating subscriptions | ||
|
||
Returns: | ||
Response that contains status URL's to monitor the orchestration | ||
""" | ||
@authorization.authorize([SecurityGroups.subscription_managers]) | ||
async def main(req: func.HttpRequest,starter:str) -> func.HttpResponse: | ||
|
||
client = df.DurableOrchestrationClient(starter) | ||
|
||
# Payload that contains how a subscription environment is created | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can be rewrite this as " # Payload that contains how the subscription environments should be created" |
||
payload: str = json.loads(req.get_body().decode()) | ||
client_name: str = req.route_params.get('clientName') | ||
orchestrator_name: str = "SubscriptionLifecycleOrchestrator" | ||
|
||
headers = {} | ||
|
||
if client_name is None: | ||
return func.HttpResponse( | ||
"Must include clientName (in the body of the http)", | ||
headers=headers, status_code=400) | ||
else: | ||
# Initialize a new status for this client in-memory | ||
status_mgr = InMemoryStatusManager(client_name) | ||
await status_mgr.safe_update_status(update_callback) | ||
payload["customerName"] = client_name | ||
payload["subscriptionId"] = None | ||
instance_id = await client.start_new(orchestration_function_name=orchestrator_name,instance_id=None,client_input=payload) | ||
logging.info(f"Started orchestration with ID = '{instance_id}'.") | ||
return client.create_check_status_response(req,instance_id) | ||
Comment on lines
+38
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it is some kind of optimization, I would prefer to remove it to simplify the complexity of this sample. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
{ | ||
"scriptFile": "__init__.py", | ||
"bindings": [ | ||
{ | ||
"authLevel": "anonymous", | ||
"name": "req", | ||
"type": "httpTrigger", | ||
"direction": "in", | ||
"route": "product/{clientName}", | ||
"methods": [ | ||
"post", | ||
"get" | ||
] | ||
}, | ||
{ | ||
"name": "$return", | ||
"type": "http", | ||
"direction": "out" | ||
}, | ||
{ | ||
"name": "starter", | ||
"type": "durableClient", | ||
"direction": "in" | ||
} | ||
] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: fixing spacing and tiny typo