Skip to content
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

Enterprise Unit Testing Sample #310

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
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
180 changes: 180 additions & 0 deletions samples/unit_testing/README.md
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.
Comment on lines +3 to +6
Copy link
Collaborator

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

Suggested change
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 project demonstrates a durable workflow that manages a subscription creation long running lifecycle 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit

Suggested change
- EasyAuth using decoraters
- EasyAuth using decorators

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the comment about list-wrapping in here. Why is that necessary?

Copy link
Collaborator Author

@priyaananthasankar priyaananthasankar Aug 19, 2021

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think I understand this now. This is similar to how map() returns a lazily-evaluated list, so it requires a call to list() in order to obtain the materialized result, isn't it?

In this case, I think it would be simpler if we continued using next(), just for consistency :)

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we returning just list here? Did you mean to return list() or []?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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`

---
17 changes: 17 additions & 0 deletions samples/unit_testing/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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we shouldn't need an .sh file to run these tests. I'll dive into the code to see if I can get these running without the need for one :) . I'll get back to you on this!

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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
}
42 changes: 42 additions & 0 deletions samples/unit_testing/subscription-manager/Auth/authorization.py
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding was that wraps is primarily used to preserve the metadata of a function (such as docstrings and attributes like __name__) and not necessarily for preserving parameters. Can you please clarify how/why it's being used here?

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
10 changes: 10 additions & 0 deletions samples/unit_testing/subscription-manager/Auth/knowngroups.py
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")
42 changes: 42 additions & 0 deletions samples/unit_testing/subscription-manager/Auth/usertoken.py
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can simplify this to
return group_id in self.group_ids :)




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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the InMemoryStatusManager here? I find it a fairly complex, for a sample, that we would need to manage some kind of "in memory" manager, it sounds like an optimization.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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"
}
]
}
Loading