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.
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:
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:
I love unit tests. They:
As a byproduct, when you automate unit tests, they also:
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:
So, we all agree that unit test rocks. Why not just do it?
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
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 (
418) and validate that the
raise_for_exception method is working in our code.
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:
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
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
So, we will need to patch the method
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!
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.
Between the two of the (and our unit tests), we have drastically increased the confidence that our code works, and increased that confidence quickly.
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: