Unit Test Primer
When creating a unit test for a plugin, the recommended practice is to store them in the root level of the plugin directory in a unit_test folder.
For larger plug-ins, we recommend separating your actions, connections, payloads and triggers into sub-folders. This will enable you to run separate tests as needed. Creating sub-folders in your unit test directory impacts the unit test import process.
Create a Unit Test
Unit tests must adhere to a specific format. Note the following as you prepare to write your unit test:
- The TestEncode class must inherit from TestCase
- All tests are functions within the TestEncode class, and they must start with
test_
Here is an example of a correctly written unit test:
python
1from unittest import TestCase23class TestEncode(TestCase):4def test_run(self):5pass
Run the Unit Test
Once you write the TestEnclose class, you can run it in PyCharm in one of the following ways:
In PyCharm, click the green arrow that appears to the left of the class.
Run the test from the command line. For more information, see here.
Note: If you're running from the command line, you'll have Python path problems. To fix this I used the following code.
python
1from unittest import TestCase2import sys3import os45working_path = os.getcwd()6sys.path.insert(0,"../")78from icon_azure_ad_admin.actions import DisableUserAccount
This code adds the root directory of the plugin structure to your Python path. That allows you to import from the icon_<plugin_name> directory.
Unit Tests WIthout External Calls
Once your unit tests are written and running, you can begin testing your code.
Before you can test your code you must have the following:
- A valid plugin.spec.yaml file
- A generated plugin
python
1from unittest import TestCase2from komand_base64.actions import Encode3import logging45class TestEncode(TestCase):6def test_run(self):7log = logging.getLogger("Test")8test_encoder = Encode()910params = {11"content": "Look mom, unit tests!"12}1314test_encoder.logger = log15actual = test_encoder.run(params)1617expected = {'data': 'TG9vayBtb20sIHVuaXQgdGVzdHMh'}1819self.assertEqual(actual, expected)
Next, you will need to configure the following settings:
- Logger
- The plugin will need a logger. We do this by importing logging and using logging.getLogger(). Thanks to the magic of Python, you can simply inject that in the class with < plugin >.logger = < log >
- Parameters
- You will need parameters if we want to run the run function. To create these quickly you can copy them from the .json test files. (If you don’t have those, generate them with ./run.sh -c samples)
- Expected
- This setting will save time if you’re working with complex output. Set expected to “” then set a breakpoint on assertEqual. Debug your unit test. It should break on the assert statement and you’ll have all your variable values in a watch window in the debug pane. If the actual value is correct, simply copy the value and paste it in expected.
Here are all the available assert statements: https://docs.python.org/3/library/unittest.html#test-cases
In general, you will use assertEquals, but there are several test asserts that are useful, such as assertTrue and assertRaises.
Tests With External Calls
A way to rapidly develop with external calls is to use Postman to make sure you have your API call correct, copy that payload into your plugin project and mock the call to the API. For large complex calls, this can lead to huge time savings.
Assumptions: You know how to use Postman and can make API calls with that tool. cURL will work too if you’re more familiar with that.
The following example is from the Office 365 Email plugin. This is a ton of code, but it’s much easier than it looks.
This simply reads payloads in as text, which makes mocking an API call much easier.
python
1This simply reads payloads in as text, which makes mocking an API call much easier.23# Get a real payload from file4def read_file_to_string(filename):5with open(filename) as my_file:6return my_file.read()
This block of code mocks the requests.get() methods. The requests.get() method simulates a Python command, making it an effective way to run your tests.
python
1# This method will be used by the mock to replace requests.get2def mocked_requests_get(*args, **kwargs):3class MockResponse:4def __init__(self, json_data, status_code):5self.json_data = json_data6self.status_code = status_code7self.text = "This is some error text"89def json(self):10return json.loads(self.json_data)1112# Since this is folder down from the base unit_test folder, the base path may change on us if we're13# running the whole suite, or just these tests.14actual_path = os.path.dirname(os.path.realpath(__file__))15actual_joined_path = os.path.join(actual_path, "payloads/get_messages_from_user.json")16get_messages_from_user_payload = read_file_to_string(actual_joined_path)1718if args[0] == 'https://login.microsoftonline.com/test_tenant_id/oauth2/token':19return MockResponse({"access_token": "test_api_token6"}, 200)20if args[0] == 'https://graph.microsoft.com/v1.0/test_tenant_id/users/bob@hotmail.com/messages?$search="from:From"&$top=250':21return MockResponse(get_messages_from_user_payload, 200)2223print(f"mocked_requests_get failed looking for: {args[0]}")24return MockResponse(None, 404)
Note the following as you configure your settings:
- Mock Object:
- Really, this class is mocking two things. The behavior of requests.get(), and it’s response object. In the first few lines of this block, you’ll see that we create an inline class contained within this method. That’s going to be the thing that’s returned when our mock is called.
- Python Path Note
- Below that, you’ll see us reading in the payloads. I have to do a trick with the path to make sure Python can find our payload regardless of where this script is being run. If you run this script from the root of unit_tests when running all the tests, your Current Working Directory (CWD) will be different than if you run just this suite or just a test in this suite.
- ARGS
- I look to see what argument we have and see what we are trying to call. I return our mock response if the argument matches one of our expected calls.
- Errors
- If the argument that comes in doesn’t match any of my expected URLs, I print that URL and bailout with a 404. Printing comes in handy so you can quickly see what failed. The 404 is handy because it allows us to test error handling in our code.
In this block of code, I create a fake connection class that I can pass into my action class.
python
1# Mock the connection2class MockConnection:3def __init__(self):4self.tenant = "test_tenant_id"5self.auth_token = "test_api_token"67def get_headers(self, api_token):8return {9"thisisaheader": "thisisavalue",10"api_token": self.auth_token11}1213def get_auth_token(self):14return self.auth_token
This code is pretty specific to the O365 plugin, but I wanted to make a note that you'll have to create mock classes for some of the common classes that our plug-ins use.
Finally... the actual test:
python
1@mock.patch('requests.get', side_effect=mocked_requests_get)2def test_get_messages_from_user(self, mock_get):3get_messages = GetEmailFromUser()4get_messages.connection = MockConnection()5actual = get_messages.get_messages_from_user("bob@hotmail.com", '$search="from:From"&$top=250')67self.assertTrue(len(actual), 3)8self.assertEqual(actual[2].get('id'), 'AAMkADI3Mzc1ZTg3LTIzYWEtNDNmNi1hZDQ5LTBiMjAzYzA3ZThhYwBGAAAAAAAxDvrPc8q6SqGLTJ9iB-SGBwC8UQDN7ObVSLWQuxHJ-dDTAAE-L_Q7AAC8UQDN7ObVSLWQuxHJ-dDTAAE-MJpHAAA=')
- Mock Patch
- The important thing to note here is the @mock.patch decorator. This is a slick function that will replace the behavior of one function with the method you specify. In this case, we’re taking requests.get and replacing that functionality with our mocked_requests_get method from above.
Here's more information about mocking
Unit Tests with Exceptions
One of the most important things we can do with non-traditional unit tess is exception handling and exotic error scenarios. (Want bad JSON from a response with a 200 status code? This is how to test it!)
python
1def test_make_kql_for_request_throws_exception(self):2get_messages = GetEmailFromUser()34with self.assertRaises(PluginException):5get_messages.make_kql_for_request("", "", "", 250)
In this example, we generate bad input for make_kql_for_request , which causes it to throw an exception. We want to make sure it throws a PluginException specifically and not some other error that the user may not know what to do with.
Opening a context with with
allows us to capture the exception and verify we have the correct exception.
Unit Tests on Triggers
It’s very difficult to test our triggers since they’re essentially an endless loop. We recommend that you move the logic out of the “main loop” of the trigger into separate functions and test them via direct unit tests.
Ultimately, you’ll still have to verify that your trigger “main loop” works. The following example demonstrates how to set up the calls and a timeout decorator to make sure you don’t hit any exceptions.
python
1# The timeout decorator allows us to kill the 'while True' loop without failing the test2@timeout_pass3@timeout_decorator.timeout(3)4@mock.patch('requests.get', side_effect=mocked_requests_get)5def test_microsoft_message_received(self, mock_get):6mr = EmailReceived()7params = {8"mailbox_id": TEST_MAILBOX_ID,9"subject_query": TEST_QUERY,10"interval": 111}12mr.connection = mockConnection()13mr.log_stream = MockDispatcher()14mr.dispatcher = MockDispatcher()15mr.logger = logging.getLogger("TestLogger")16mr.current_time = maya.MayaDT('2019-08-05T14:57:40Z')1718mr.run(params)
@timeout_pass
- tells the test framework that a timeout is passing testing
@timeout_decorator.timeout(3)
- tells the test to timeout after 3 seconds. With our interval set to 1, that should give us two loops which are enough to verify we've passed this test.
Appendix
- Python Unit Test Framework: https://docs.python.org/3/library/unittest.html
- Python Mocks https://docs.python.org/3/library/unittest.mock.html
- Python timeout decorator: https://pypi.org/project/timeout-decorator/