Published: March 28, 2022

Unit Testing Azure SDK for Python

Mocking, Monkey Patching, and Microsoft


Unit Testing Azure SDK for Python

Testing (especially unit testing) is a great way to build determinism into your software. But, when external API calls are involved, it can become challenging to build and test quickly and with idempotence. AND, if the code making these calls is part of a third-party package or library, it can be even more challenging.

Luckily, most languages and testing platforms account for this complexity using some semblance of mocking or patching. Here, I want to discuss how we use “monkey patching” in Python to unit test usage of the Azure Python SDK in our security software.

Background

Azure provides us nearlly all of the underlying infrastructure we require for our incredible security operations. From alerting in Sentinel, to automation with Logic Apps, to custom analysis with Azure Fuctions. All we need:

  • is to glue the pieces together automatically
  • apply some of our analysis and procedural magic
  • share the information or response back in an elegant way

Using Python (which is deeply integrated into the Microsoft ecosystem), Github, and solid SecDevOps practices allows us to accomplish this in a neatly integrated way. All part of our SOARganic framework, our Security Operations can take advantage of tried and true software development practices to build:

  • testable alert rules
  • testable automation rules
  • testable playbooks
  • testable response

Why Unit Test at all?

I love unit tests. They:

  • Ensure developers think through small parts of functionality
  • Help structure code in a functional way
  • Create simple, straightforward code
  • Create readable code

As a byproduct, when you automate unit tests, they also:

  • reduces breaking changes
  • provide a higher level of confidence in commits

The idea is that you can execute the tests frequently, as needed to ensure your changes are working. Frequently running these tests will cause issues if the tests:

  • touch data
  • reach to external services
  • require secrets

So, we all agree that unit test rocks. Why not just do it?

Challenges with HTTP requests

We use the Azure SDK for Python in several of our security platforms. It does an amazing job of interfacing with the Azure ecosystem and honestly, makes development easier (versus generating manual API calls.) But, there is a bit of magic baked into the package which can make unit tests complex.

Often, an API call like this could be “mocked” using something like the requests-mock library. Consider the following contrived example:

import requests


def make_request():
    response = requests.request("GET", "https://meetascent.com")
    response.raise_for_status()
    return response

The above code will make an HTTP GET request to the specified URL. This is an easy call to mock because we specify all the logic and actually create request. In order to unit test the above, we would want to run tests with some various HTTP Status codes in the response, some errors in the response, some successful messages, etc. And we would like to do this without requesting the site every time. What if the site is ACTUALLY down, our tests would break even though the code is fine.

import pytest
from requests.exceptions import HTTPError
from requests_mock import Mocker

from examples import make_request


def test_make_request():
    with Mocker() as m:
        m.register_uri("GET", "https://meetascent.com", text="Got it!", status_code=200)
        response = make_request()
    assert response


def test_make_request_teapot():
    with Mocker() as m:
        m.register_uri("GET", "https://meetascent.com", text="Am teapot", status_code=418)
        with pytest.raises(HTTPError) as e:
            _ = make_request()
        assert e

In the above tests, test_make_request uses the requests-mock library to create a Mocker. Then, we register the URI (which we know) and provide a known, pre-determined result (Got it with a status code of 200), which we can test against.

Alternatively, we can also test the case when the response fails using the pytest.raises context manager. We define a failed response (Am teapot, 418) and validate that the raise_for_exception method is working in our code.

Challenges with 3rd Party packages

Now, consider the method (forgive the long namespacing): azure.mgmt.storage.StorageManagementClient.storage_accounts.check_name_availability which queries the Azure Storage Account API to see if a specified account name is available. The backed magic of this method:

  • Checks authentication
  • Checks authorization
  • Validates requests
  • Validates responses

Attempting mock all of these requests, provide expected responses to the library, and handle the next steps just became exponentially more challenging. Instead we can use a concept in Python called “Monkey Patching.” The idea here is to “replace” the called method, with one of our own definition.

In the below contrived example, the defined method return_7 will always return the value 7:

class Foo:
    def return_7(self, a):
        return 7

But, we can monkey patch (or temporarily replace) the functionality of the method. Pytest even has built in functionality to accomplish this using a fixture.

from examples import Foo


def test_foo(monkeypatch):
    def patch_return_7(_):
        return 8
    
    monkeypatch.setattr(Foo, Foo.return_7.__name__, patch_return_7)
    result = Foo().return_7()
    assert result
    assert result != 7

monkeypatch.setattr replaces the original method Foo.return_7 with the “patched” method of our choice. In this case test_foo.patch_return_7 which will return the value 8 and the test will pass.

So, we can copy the same construct as above, but use it replace the magic filled Azure SDK method as such:

from azure.identity import DefaultAzureCredential
from azure.mgmt.storage import StorageManagementClient


class AzureSDK:
    def check_name_available(self, account_name):
        client = StorageManagementClient(DefaultAzureCredential, "the-subscription-id")
        response = client.storage_accounts.check_name_availability(account_name)
        return response

In the above code, the AzureSDK.check_name_available method will query the Azure Storage Account API and check if the account name specified is available. If we inspect the code, we can see that storage_accounts is of type azure.mgmt.storage.[version].StorageAccountsOperations. So, we will need to patch the method azure.mgmt.storage.[version].StorageAccountsOperations.check_name_availability. Inspecting that code, we see it returns a response of type azure.mgmt.storage.[version].models.CheckNameAvailabilityResult and we will need to mock this type.

from azure.mgmt.storage.v2021_08_01.operations import StorageAccountsOperations

from examples import AzureSDK


class MockAvailableResult:
    name_available = True

class MockStorageAccountOperations:
    def check_name_availability(instance, name):
        assert instance
        assert name


def test_check_name_available(monkeypatch):
    def mock_check_name_availability(*args):
        MockStorageAccountOperations.check_name_availability(*args)
        result = MockAvailableResult
        result.name_available = True
        return result

    # Replace the call to check_name_availability with a method of our own
    monkeypatch.setattr(
        StorageAccountsOperations,
        StorageAccountsOperations.check_name_availability.__name__,
        mock_check_name_availability,
    )
    result = AzureSDK().check_name_available("mock-name")
    assert result


def test_check_name_error(monkeypatch):
    def mock_check_name_availability(*args):
        raise HttpResponseError("Mock error checking name availability")

    monkeypatch.setattr(
        StorageAccountsOperations,
        StorageAccountsOperations.check_name_availability.__name__,
        mock_check_name_availability,
    )
    with pytest.raises(HttpResponseError) as e:
        _ = AzureSDK().check_name_available("mock-name")
    assert e

Now, when we run the test, the test will run the “patched” method in place of the specified external requirement. Hooray! Our unit tests won’t reach out to the network, touch real APIs, or change data in any way. AND we can build testable outputs for all of the methods!

What risk do we assume?

When replacing/mocking/patching an existing method, we do run the risk of replacing real bugs with our “good” code. But, there are a several options we can use to reduce our potential risk.

  • Integration testing: Later run a longer, full test suite to ACTULLY touch the external API (in a test env) and execute the real code (stay tuned for a follow up on this exact topic!)
  • SDK test suites: The maintainers of the code build tests into their package/library that we can run if desired.

Between the two of the (and our unit tests), we have drastically increased the confidence that our code works, and increased that confidence quickly.

Conclusion

Mocking and Monkey Patching are excellent patterns to simplify unit testing when relient on external, “uncontrolled” packages. Especially if these packages make API calls, touch data, or take a long time to execute. Python itself, and many testing frameworks, natively support these concepts and we should also consider using them when appropriate!

While these concepts are originally specific to software development, the positive impact they can have on security operations is tangible:

  • better, tested analytics rules
  • better, tested automation rules
  • better, tested playbooks and pipelines
  • an “all-around” higher level of confidence in the software components of your security operations