Pytest in the Networking World – Part 3

Blog Detail

This blog post will be the final entry in my “Pytest in the Networking World” blog series. Part 1 can be found here and Part 2 can be found here. I recommend reading through both so you have the basic knowledge of how pytest works and some insight into parameterization. In this post, I will be going over various aspects of mocking.

Why Do We Need Mocking?

Keeping in the same vein as our last blog, let’s say we wrote a function using netmiko that queries our Cisco 3650 for its MAC address table so that we can use that information and pass it to our normalize_mac_address function. Sometimes, this call completes in under a second. Other times, it may take up to a few seconds. When we write tests for our code, do we want to possibly wait a few seconds for our call to the 3650 to complete? We definitely don’t. Unit tests should be fast so that developers are more likely to utilize them. Is there a way we could possibly fake the API call? There is, and it’s called mocking! When we mock a function, we make a “dummy” copy of that function where we can control the logic in the function and what that function returns. Let’s walk through an example of how we’d take advantage of mocking.

Updating Our Files

At the end of the last blog, our Python file contained the following:

>>> NON_NORMALIZED_MACS = ["aabb.ccdd.eeff", "0011.2233.4455", "aa11.bb22.cc33"]
>>> NORMALIZED_MACS = ["aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", "aa:11:bb:22:cc:33"]
>>>
>>> @pytest.mark.parametrize("param_non_normalized_mac, param_normalized_mac", list(zip(NON_NORMALIZED_MACS, NORMALIZED_MACS)))
>>> def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
>>>     assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac

Using parameterization, we were able to test our normalize_mac_address function with multiple MAC addresses while ensuring each set of data was treated as its own test. Now, let’s add our function that will connect to a Cisco 3650 and get its MAC address table to the same file that the normalize_mac_address function is in. We’ll also add a function that will get the MAC address table and normalize it all at once. Our file should now look like this:

>>> from netmiko import ConnectHandler
>>> from datetime import datetime
>>>
>>> def normalize_mac_address(mac):
>>>     if mac.count(".") == 2:
>>>         mac = f"{mac[0:2]}:{mac[2:4]}:{mac[5:7]}:{mac[7:9]}:{mac[10:12]}:{mac[12:14]}"
>>>     return mac
>>>
>>> def get_mac_address_table():
>>>     start_time = datetime.now()
>>>
>>>     mydevice = {
>>>         "device_type": "cisco_ios",
>>>         "host": "YOUR DEVICE IP",
>>>         "username": "YOUR DEVICE USERNAME",
>>>         "password": "YOUR PASSWORD FOR THE CONNECTING USER"
>>>     }
>>>     command = "show mac address-table"
>>>
>>>     net_connect = ConnectHandler(**mydevice)
>>>     output = net_connect.send_command(command, use_textfsm=True)
>>>     net_connect.disconnect()
>>>
>>>     print(f"\nTime to run: {datetime.now() - start_time}")
>>>     return output
>>>
>>> print(get_mac_address_table())

For information on Netmiko and TextFSM, check here.

I included some code so we could time how long it takes to run this function. Let’s run it now.

[{'destination_address': '0100.0ccc.cccc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0100.0ccc.cccd', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0000', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0001', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0002', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0003', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0004', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0005', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0006', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0007', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0008', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0009', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000a', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000b', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000c', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000d', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000e', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.000f', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0010', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0180.c200.0021', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': 'ffff.ffff.ffff', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, {'destination_address': '0014.1c57.a488', 'type': 'DYNAMIC', 'vlan': '300', 'destination_port': 'Gi1/0/1'}, {'destination_address': '00c8.8bca.55c7', 'type': 'STATIC', 'vlan': '300', 'destination_port': 'Vl300'}, {'destination_address': '0014.1c57.a488', 'type': 'DYNAMIC', 'vlan': '400', 'destination_port': 'Gi1/0/1'}, {'destination_address': '00c8.8bca.55d2', 'type': 'STATIC', 'vlan': '400', 'destination_port': 'Vl400'}, {'destination_address': '0014.1c57.a488', 'type': 'DYNAMIC', 'vlan': '450', 'destination_port': 'Gi1/0/1'}, {'destination_address': '00c8.8bca.55fd', 'type': 'STATIC', 'vlan': '450', 'destination_port': 'Vl450'}, {'destination_address': '0014.1c57.a488', 'type': 'DYNAMIC', 'vlan': '500', 'destination_port': 'Gi1/0/1'}, {'destination_address': '00c8.8bca.55d0', 'type': 'STATIC', 'vlan': '500', 'destination_port': 'Vl500'}]

Time to run: 0:00:04.369203

This gets us a list of dictionaries each containing the destination address, type, vlan, and destination port for each entry in the table. On the last line, you can see it took 4.4 seconds to connect to the device, run the command, parse the output, and present it back to us. Next we’ll need to update our normalize_mac_address function to account for the new data structure. Let’s also add a function to combine getting the MAC address table and normalizing it all in one function.

>>> from netmiko import ConnectHandler
>>> from datetime import datetime
>>>
>>> def normalize_mac_address(macs):
>>>     for entry in macs:
>>>         if entry["destination_address"].count(".") == 2:
>>>             new_mac = f"{entry['destination_address'][0:2]}:{entry['destination_address'][2:4]}:{entry['destination_address'][5:7]}:{entry['destination_address'][7:9]}:{entry['destination_address'][10:12]}:{entry['destination_address'][12:14]}"
>>>             entry['destination_address'] = new_mac
>>>     return macs
>>>
>>> def get_mac_address_table():
>>>     start_time = datetime.now()
>>>
>>>     mydevice = {
>>>         "device_type": "cisco_ios",
>>>         "host": "YOUR DEVICE IP",
>>>         "username": "YOUR DEVICE USERNAME",
>>>         "password": "YOUR PASSWORD FOR THE CONNECTING USER"
>>>     }
>>>     command = "show mac address-table"
>>>
>>>     net_connect = ConnectHandler(**mydevice)
>>>     output = net_connect.send_command(command, use_textfsm=True)
>>>     net_connect.disconnect()
>>>
>>>     print(f"\nTime to run: {datetime.now() - start_time}\n")
>>>     return output
>>>
>>>
>>> def get_mac_table_and_normalize():
>>>     macs = get_mac_address_table()
>>>     normalized_macs = normalize_mac_address(macs)
>>>     return normalized_macs
>>>
>>> print(get_mac_table_and_normalize())

If you run this now, you’ll see that the MAC addresses in the dictionary have been normalized.

[{'destination_address': '0100.0ccc.cccc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, ...,
 {'destination_address': '00c8.8bca.55d0', 'type': 'STATIC', 'vlan': '500', 'destination_port': 'Vl500'}]

Time to run: 0:00:04.849528

[{'destination_address': '01:00:0c:cc:cc:cc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}, ...,
{'destination_address': '00:c8:8b:ca:55:d0', 'type': 'STATIC', 'vlan': '500', 'destination_port': 'Vl500'}]

Middle entries have been taken out for brevity.

Great! Our new function works as expected. Now we’ll turn toward updating our tests file. Currently it looks like:

NON_NORMALIZED_MACS = ["aabb.ccdd.eeff", "0011.2233.4455", "aa11.bb22.cc33"]
NORMALIZED_MACS = ["aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", "aa:11:bb:22:cc:33"]

@pytest.mark.parametrize("param_non_normalized_mac, param_normalized_mac", list(zip(NON_NORMALIZED_MACS, NORMALIZED_MACS)), ids=[x for x in NON_NORMALIZED_MACS])
def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
    assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac

This test will currently fail, as we have changed the normalize_mac_address function. For the sake of staying on topic, we’ll comment out that test and focus on testing out get_mac_table_and_normalize. Here is our file with a basic test written for our new function and our old test commented out.

>>> import pytest
>>> from mac_address import normalize_mac_address, get_mac_table_and_normalize
>>>
>>> #NON_NORMALIZED_MACS = ["aabb.ccdd.eeff", "0011.2233.4455", "aa11.bb22.cc33"]
>>> #NORMALIZED_MACS = ["aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", "aa:11:bb:22:cc:33"]
>>> #
>>> #@pytest.mark.parametrize("param_non_normalized_mac, param_normalized_mac", list(zip(NON_NORMALIZED_MACS, NORMALIZED_MACS)), ids=[x for x in NON_NORMALIZED_MACS])
>>> #def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
>>> #    assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac
>>>
>>> def test_get_mac_table_and_normalize():
>>>     macs = get_mac_table_and_normalize()
>>>     assert True

Let’s run our test and look at the output.

============================================================= test session starts ==============================================================================
platform linux -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py .                                                                                                                                      [100%]

============================================================== 1 passed in 8.95s ================================================================================

The test passes (because we are currently just asserting True), but you’ll notice that on the last line it states that our test passed in 8.95 seconds. We know that connecting to the device and retrieving the MAC address table is taking roughly 4 seconds, so let’s jump into how we’d mock this function.

Mocking

Let me update our function to leverage mocking, and then I’ll go over it.

>>> import pytest
>>> import re
>>> from mac_address import normalize_mac_address, get_mac_table_and_normalize
>>> from unittest.mock import patch
>>>
>>> @patch('mac_address.get_mac_address_table')
>>> def test_get_mac_table_and_normalize(mock_get_mac_address_table):
>>>     mock_get_mac_address_table.return_value = [{'destination_address': '01:00:0c:cc:cc:cc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}]
>>>     macs = get_mac_table_and_normalize()
>>>     normalized_mac = macs[0].pop("destination_address")
>>>
>>>     assert re.match(r'([0-9a-fA-F]{4}[.]){2}([0-9a-fA-F]{4})', normalized_mac)
>>>     mock_get_mac_address_table.assert_called_once()

Commented test left out for brevity’s sake.

The first thing you may notice is that there are two new imports, import re and from unittest.mock import patch. The re library is not a necessary import to use mocks. I imported it for use in an assert statement that I’ll go over shortly. The unittest.mock import patch is necessary. This gives us access to the @patch() decorator that you see implemented on line 6. This decorator is what is actually doing the mocking. To use the decorator, you pass in the object that you want to mock using the syntax @patch(module.functionA) or @patch(module.ClassA). In this case, when I’m testing get_mac_table_and_normalize, I want to mock the get_mac_address_table function that gets called in my get_mac_table_and_normalize function. I implement my decorator using @patch('mac_address.get_mac_address_table'); and in my test function definition, I add the argument mock_get_mac_address_table. When you mock an object, that mocked object gets passed into the decorated function as an argument so you need to account for that. I typically name the function with mock_ prepended to it.

On line 8, I define what I want the return value to be when my mocked function is called. I can make this whatever I want, but you typically would make this similar to what you would expect from the original function. Line 9, I call our get_mac_table_and_normalize function. Line 10, I get the value for the destination_address key from the first dictionary in the returned list from calling get_mac_table_and_normalize and store it in a variable. In lines 12 and 13, I have two assertions taking place. The first ensures that the value I popped from our returned list conforms to our normalized MAC address format by leveraging regex. The second asserts that our mock function was called only one time. Let’s run our newly written test and check out the results.

=========================================================== test session starts =======================================================================================
platform linux -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/blog/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py::test_get_mac_table_and_normalize PASSED                                                                                                      [100%]

========================================================== 1 passed in 4.59s ===========================================================================================

You’ll notice our test passes and that it now took 4.59 seconds to complete. That is exactly what we expected as we mocked our call to our network device which took roughly 4 seconds. To provide another example, let’s say we wanted to mock the normalize_mac_address function. How would we go about that? We can follow the same process we did for the get_mac_address_table function.

>>> import pytest
>>> import re
>>> from mac_address import normalize_mac_address, get_mac_table_and_normalize
>>> from unittest.mock import patch
>>>
>>> @patch('mac_address.get_mac_address_table')
>>> @patch('mac_address.normalize_mac_address')
>>> def test_get_mac_table_and_normalize(mock_normalize_mac_address, mock_get_mac_address_table):
>>>     mock_get_mac_address_table.return_value = [{'destination_address': '01:00:0c:cc:cc:cc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}]
>>>     mock_normalize_mac_address.return_value = [{'destination_address': '0100.0ccc.cccc', 'type': 'STATIC', 'vlan': 'All', 'destination_port': 'CPU'}]
>>>     macs = get_mac_table_and_normalize()
>>>     normalized_mac = macs[0].pop("destination_address")
>>>
>>>     assert re.match(r'([0-9a-fA-F]{4}[.]){2}([0-9a-fA-F]{4})', normalized_mac)
>>>     mock_get_mac_address_table.assert_called_once()

We are now mocking both functions that are called when we call get_mac_table_and_normalize. We added a patch decorator and included mac_address.normalize_mac_address in its parameter. We also have to add another argument to our test function definition. When mocking more than one object, the lowest patch decorator is mapped to the leftmost argument in the function definition. If we run this, we get the following output:

======================================================== test session starts =============================================================================================
platform linux -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py .                                                                                                                                                [100%]

======================================================== 1 passed in 4.45s ================================================================================================

Our test again passes. This time we have only a slight decrease in test time to 4.45 seconds. This was expected as the normalize_mac_address function is not doing anything extensive, so mocking it didn’t yield much decrease in computation time.

To prove the test is using the mocked return values, you can change the line 10 destination address to something that doesn’t conform to our normalized MAC format and the test will fail.

Mocking Tips

  • There are different implementations of mocking in pytest. I chose the above method because I felt it was the easiest to see and wrap your head around. To see the other implementations of mocking, you can check out the documentation here.
  • When referring to the object you want to mock in the decorator argument, you need to think about mocking the object where it is looked up rather than where it may be defined. This documentation goes more in-depth.
  • You can do a wide variety of things with a mocked object. You can manipulate it to return whatever you want (as shown in our example) or have it return dynamic results using side effects. More information on that and other ways you can manipulate mock functions can be found here.
  • A multitude of assertions can be made on mocked functions. The documentation here goes over them.

Conclusion

This blog is just the tip of the iceberg when it comes to mocking. It’s a pretty extensive feature and can get very complicated very quickly. While this application of mocking may not be the most practical, hopefully these examples were easy enough to follow along with and you were able to gather what mocking is and one of its applications. If you are interested in looking into more robust applications, you can check out the tests for our pyntc repository here. If you have any questions or want to discuss any tests we at NTC may have written that you’d like clarification on, definitely come by and reach out to us on our NTC community Slack.

-Adam



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Pytest in the Networking World – Part 2

Blog Detail

This blog post will be a continuation of the post found here. I recommend reading through that so you have the basic knowledge of how pytest works. This post will go over various aspects of parameterization. It is a very powerful tool that allows us to run tests in a clean and efficient way.

Why Do We Need Parameterization?

From the previous blog, we had a test checking that our function normalized a MAC address as expected. Typically when we test our functions, we are going to be using more than one piece of data to test. So let’s now say we want to test multiple MAC addresses. If we continued the same approach from the last blog, we would write two fixtures, one for the non normalized MAC and one for the expected normalized MAC, and a test for each MAC address. Let’s update our test_mac_address.py file from the last blog with the below code.

@pytest.fixture
def non_normalized_mac_address_1():
    return "aabb.ccdd.eeff"

@pytest.fixture
def expected_mac_address_1():
    return "aa:bb:cc:dd:ee:ff"

@pytest.fixture
def non_normalized_mac_address_2():
    return "0011.2233.4455"

@pytest.fixture
def expected_mac_address_2():
    return "00:11:22:33:44:55"

@pytest.fixture
def non_normalized_mac_address_3():
    return "aa11.bb22.cc33"

@pytest.fixture
def expected_mac_address_3():
    return "aa:11:bb:22:cc:33"

def test_normalize_mac_address_1(non_normalized_mac_address_1, expected_mac_address_1):
    normalized_mac_address = normalize_mac_address(non_normalized_mac_address_1)
    assert normalized_mac_address == expected_mac_address_1

def test_normalize_mac_address_2(non_normalized_mac_address_2, expected_mac_address_2):
    normalized_mac_address = normalize_mac_address(non_normalized_mac_address_2)
    assert normalized_mac_address == expected_mac_address_2

def test_normalize_mac_address_3(non_normalized_mac_address_3, expected_mac_address_3):
    normalized_mac_address = normalize_mac_address(non_normalized_mac_address_3)
    assert normalized_mac_address == expected_mac_address_3

We are now testing that our function performs as expected even with different MAC addresses. Running the tests produces this output:

====================================================== test session starts =========================================================================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 3 items

test_mac_address.py::test_normalize_mac_address_1 PASSED                                                                                      [ 33%]
test_mac_address.py::test_normalize_mac_address_2 PASSED                                                                                      [ 66%]
test_mac_address.py::test_normalize_mac_address_3 PASSED                                                                                      [100%]

===================================================== 3 passed in 0.01s ============================================================================

Nice! All of our tests pass and we get output for each MAC address we test. However, as you can probably tell, that’s a lot of code to check that our function works with different MAC addresses. What would happen if we wanted to test 25 MAC addresses? We’d have 50 fixtures and 25 individual tests all to test one function. This approach isn’t scalable and would be quite difficult to maintain.

Another approach might be to have the fixtures return lists and have a for loop iterate through each item to test the MAC addresses. Let’s code that approach.

@pytest.fixture
def non_normalized_mac_list():
    return ["aabb.ccdd.eeff", "0011.2233.4455", "aa11.bb22.cc33"]

@pytest.fixture()
def normalized_mac_list():
    return ["aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", "aa:11:bb:22:cc:33"]

def test_normalize_mac_address_lists(non_normalized_mac_list, normalized_mac_list):
    for sequence, mac_address in enumerate(non_normalized_mac_list):
        assert normalize_mac_address(mac_address) == normalized_mac_list[sequence]

Let’s run this:

==================================================== test session starts ============================================================================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py::test_normalize_mac_address_lists PASSED                                                                                    [100%]

==================================================== 1 passed in 0.01s ===============================================================================

This approach helps our scalability problem. We now only need to add to a list to test new MAC addresses rather than create new fixtures or tests. The biggest problem with this approach is that we aren’t truly testing each MAC address. We are actually testing that every item in the list, as a whole, is successfully being normalized. The test will immediately fail if one of the items in the list doesn’t assert to True. Let me demonstrate what I mean. Let’s take the non_normalized_mac_list fixture and update it like so:

@pytest.fixture
def non_normalized_mac_list():
    return ["aabb.ccdd.eeff", "0011-2233-4455", "aa11-bb22-cc33"]

Let’s run the code with the new change.

=================================================== test session starts =============================================================================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py::test_normalize_mac_address_lists FAILED                                                                                   [100%]

======================================================= FAILURES =====================================================================================
______________________________________________ test_normalize_mac_address_lists ______________________________________________________________________

non_normalized_mac_list = ['aabb.ccdd.eeff', '0011-2233-4455', 'aa11.bb22.cc33'], normalized_mac_list = ['aa:bb:cc:dd:ee:ff', '00:11:22:33:44:55', 'aa:11:bb:22:cc:33']

    def test_normalize_mac_address_lists(non_normalized_mac_list, normalized_mac_list):
        for number in range(0, len(non_normalized_mac_list)):
>           assert normalize_mac_address(non_normalized_mac_list[number]) == normalized_mac_list[number]
E           AssertionError: assert '0011-2233-4455' == '00:11:22:33:44:55'
E             - 00:11:22:33:44:55
E             ?   -  ^  -  ^  -
E             + 0011-2233-4455
E             ?     ^    ^

test_mac_address.py:14: AssertionError
================================================== short test summary info ============================================================================
FAILED test_mac_address.py::test_normalize_mac_address_lists - AssertionError: assert '0011-2233-4455' == '00:11:22:33:44:55'
===================================================== 1 failed in 0.02s ===============================================================================

We haven’t updated our normalize_mac_address function so the test fails as expected. But, ideally, we’d see failures for both the 2nd item and the 3rd item. We’re only seeing the failure for the 2nd item and then our test exits. Even though using a list allows us to test a large amount of MAC addresses with little code, it doesn’t provide us the correct kind of testing we need. That’s where parameterization comes in!

Parameterization

Parameterization is a process of running the same test with varying sets of data. The most important aspect is that each set of data passed into a test is treated as its own test. You can parameterize both fixtures and test functions in pytest, but we will only be looking at parameterizing our test function in this blog. Once you grasp the concept on test function, you can find more information about parameterizing fixtures here. I’m going to show you the code to parameterize our test function and then I’ll break it down.

@pytest.mark.parametrize(
    "param_non_normalized_mac, param_normalized_mac",
    [
        ("aabb.ccdd.eeff", "aa:bb:cc:dd:ee:ff"),
        ("0011.2233.4455", "00:11:22:33:44:55"),
        ("aa11.bb22.cc33", "aa:11:bb:22:cc:33" )
    ]
)
def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
    assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac

To parameterize a test function we use the decorator @pytest.mark.parametrize(). This decorator defines the names of the function arguments to parameterize as well as what values to assign to the arguments for each iteration. The function arguments to parameterize are defined using a comma-separated string. In our example I defined two arguments to parameterize: param_non_normalized_mac and param_normalized_mac. The next part of the decorator is where we actually define what values those arguments will take. Since we specified 2 arguments we create a list of tuples with each tuple containing 2 items. When we run the test, the “param_non_normalized_mac and param_normalized_mac` arguments will iterate over the list defined in the decorator and take the values defined in the tuples. Let’s run the new parameterized test function.

====================================================== test session starts ============================================================================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 3 items

test_mac_address.py::test_normalize_mac_address_lists[aabb.ccdd.eeff-aa:bb:cc:dd:ee:ff] PASSED                                                    [ 33%]
test_mac_address.py::test_normalize_mac_address_lists[0011.2233.4455-00:11:22:33:44:55] PASSED                                                    [ 66%]
test_mac_address.py::test_normalize_mac_address_lists[aa11.bb22.cc33-aa:11:bb:22:cc:33] PASSED                                                    [100%]

======================================================= 3 passed in 0.01s ==============================================================================

From the output we can see our test ran 3 distinct times with each test being able to fail independently of the others. This is exactly what we wanted. Let’s take a quick look at the output. At the end of the test name there are some values in brackets. These are the test IDs. These show up when you parameterize a test and can be used to call any test instance individually. The values default to the arguments passed into the test function, and pytest handles creating the ID differently based on the type of parameters passed in. Since we are passing strings into our test function, pytest concatenates the values using a hyphen when creating the test. If we wanted to run just a specific test from the parameterized test function, all we’d need to do is use the ID found above. This command python3 -m pytest test_mac_address.py::test_normalize_mac_address_lists[0011.2233.4455-00:11:22:33:44:55] -v would run only the 2nd test out of the three.

Parameterization Tips and Tricks

Remember that when defining the list of values that the test parameters will take, you can use things such as the zip() function. An example of that using our test function might look like this.

NON_NORMALIZED_MACS = ["aabb.ccdd.eeff", "0011.2233.4455", "aa11.bb22.cc33"]
NORMALIZED_MACS = ["aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", "aa:11:bb:22:cc:33"]

@pytest.mark.parametrize(
    "param_non_normalized_mac, param_normalized_mac",
    zip(NON_NORMALIZED_MACS, NORMALIZED_MACS),
)
def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
    assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac

We can define our own IDs when we parameterize our tests. One common approach is to use list comprehension to obtain some specific value from the data passed into the test function. To show that, here is an example of grabbing the last 4 characters of each normalized MAC address from the NON_NORMALIZED_MACS list to use as IDs.

@pytest.mark.parametrize(
    "param_non_normalized_mac, param_normalized_mac",
    zip(NON_NORMALIZED_MACS, NORMALIZED_MACS),
    ids=[x[-4:] for x in NON_NORMALIZED_MACS],
)
def test_normalize_mac_address_lists(param_non_normalized_mac, param_normalized_mac):
    assert normalize_mac_address(param_non_normalized_mac) == param_normalized_mac
======================================================== test session starts ==========================================================================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 3 items

test_mac_address.py::test_normalize_mac_address_lists[eeff] PASSED                                                                                [ 33%]
test_mac_address.py::test_normalize_mac_address_lists[4455] PASSED                                                                                [ 66%]
test_mac_address.py::test_normalize_mac_address_lists[cc33] PASSED                                                                                [100%]

======================================================= 3 passed in 0.01s =============================================================================

Lastly, by default, pytest doesn’t show standard output of tests that pass (i.e., print statements). To get those to show, you need to add -rP to your command when you run the tests.


Conclusion

This blog is really just a small portion of what parameterization can do. In the last example you saw how parameterization can be used to easily test your functions with varying sets of data in a clean and concise way. This example can be built upon to create your own tests. My advice would be to clone our netutils library and take a look at the tests. They are real-world examples that take the same approach shown here. If you want to dig deeper into parameterization in pytest, you can check out its official documentation here and if you have any questions, definitely come by and reach out to us on our NTC community Slack.

In my next blog post I’ll go over the concept of mocks in the pytest framework. Thanks for reading!

-Adam



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Pytest in the Networking World

Blog Detail

Like many of you, I come from a purely networking background. When I first started my network automation journey, I felt I was making great strides. However, I was, unknowingly, missing a very important component in my solutions—testing. I don’t mean testing what my code did in the network; I mean testing my code to ensure it acts as expected. That’s where pytest comes in. There are numerous examples of pytest in action on the internet but none showing those examples with networking data. As a somewhat visual learner myself, seeing an example of code with arbitrary data (e.g., Foo, Bar, i, j) versus seeing it with networking data (e.g., GigabiteEthernet1/1, Vlan100, etc.) makes a world of a difference to me. In this blog post I will give a quick overview on what pytest is, some of its important compenents, and show an example of how it can be used to test a function that normalizes MAC addresses.

What Is Pytest?

Pytest is a framework that helps you write unit and integration tests for your code. At its core, it’s pretty simple. However, that simplicity allows the pytest framework to be scaled very easily to accurately test large projects a well as lend itself to a test-driven development (TDD) approach to writing code. I’ll take this approach when I explain examples down below. Pytest has some cool features that you need to know about to understand the examples.

  • Any files following the conventions test_*.py or *_test.py are automatically discovered by pytest.
  • Any functions with test prefixed are automatically discovered.
  • Fixtures can be used to pass generated arguments to your tests.

You can read more in-depth about pytest here.

Pytest Example

Let’s say we are gathering MAC address info from our campus switches. After looking through what we’ve gathered, we notice we’re getting MAC addresses in two different formats:

  • aa:bb:cc:dd:ee:ff
  • aabb.ccdd.eeff

We need to normalize these to a single format. We decide that we want all MACs to be in the format aa:bb:cc:dd:ee:ff. Taking a TDD approach, we want to write a test before we actually develop any code. Our goal is to take a non-normalized MAC address, format it, and assert that the newly formated MAC address conforms to our defined standard. With that in mind, let’s create a file called test_mac_address.py and create the test:

>>> import pytest
>>>
>>>
>>> def test_normalize_mac_address(non_normalized_mac_address, expected_mac_address):
>>>     normalized_mac_address = normalize_mac_address(non_normalied_mac_address)
>>>     assert normalized_mac_address == expected_mac_address

Let’s break this down. The first line is defining our function, test_normalize_mac_address, which is expecting 2 arguments. Where are these arguments coming from? The arguments are coming from what pytest calls fixtures. These will be discussed in the next section. Our next line calls the function normalize_mac_address (which we have yet to define) passing in our non-normalized MAC address. Finally, the last line asserts that the MAC returned from our function is what we expect. Now that we’ve gone over what each line is doing, let’s quickly discuss fixtures.

Adding Fixtures

Fixtures are functions that are run before tests and are used to pass arguments into test functions. They are defined by the decorator @pytest.fixture. I mentioned before that we had two arguments we needed to pass into our test, so let’s make those two fixtures. In the same file where added our test function, let’s add these two fixtures:

>>> @pytest.fixture
>>> def non_normalized_mac_address():
>>>     return "aabb.ccdd.eeff"
>>> @pytest.fixture
>>> def expected_mac_address():
>>>     return "aa:bb:cc:dd:ee:ff"

You’ll notice the names of these fixtures match the names of the arguments in our test function. This must be done this way. When we run our test function, the two arguments are noticed and pytest looks for fixtures that have the same name. The fixture functions are then run and the returned value is used as the argument into the original test function.

Adding Our normalize_mac_address Function

Lastly, we need to add our normalize_mac_address function that we defined on the second line of our test function. Let’s create a file in the same directory we created our test file and name it mac_address.py. In this file let’s put our function:

def normalize_mac_address(non_normalized_mac_address):
    return non_normalized_mac_address

For now, lets return the non-normalized MAC address to see what results pytest displays. We need to do one last thing before we run our pytest, and that is telling our test_mac_address.py file where to find the normalize_mac_address function. We can do this by adding the line:

from mac_address import normalize_mac_address

Our test_mac_address.py file, in its complete form, should look like this.

>>> import pytest
>>> from mac_address import normalize_mac_address
>>>
>>> def test_normalize_mac_address(non_normalized_mac_address, expected_mac_address):
>>>     normalized_mac_address = normalize_mac_address(non_normalied_mac_address)
>>>     assert normalized_mac_address == expected_mac_address
>>>
>>> @pytest.fixture
>>> def non_normalized_mac_address():
>>>     return "aabb.ccdd.eeff"
>>>
>>> @pytest.fixture
>>> def expected_mac_address():
>>>     return "aa:bb:cc:dd:ee:ff"

Running pytest

Now, let’s run our test! In the same directory as our two files, let’s run pytest -v.

===================================================== test session starts =========================================
platform linux -- Python 3.8.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py::test_normalize_mac_address FAILED                                                        [100%]

========================================================= FAILURES =================================================

non_normalized_mac_address = 'aabb.ccdd.eeff', expected_mac_address = 'aa:bb:cc:dd:ee:ff'

    def test_normalize_mac_address(non_normalized_mac_address, expected_mac_address):
        normalized_mac_address = normalize_mac_address(mac_address)
>       assert normalized_mac_address == expected_mac_address
E       AssertionError: assert 'aabb.ccdd.eeff' == 'aa:bb:cc:dd:ee:ff'
E         - aa:bb:cc:dd:ee:ff
E         ?   -  ^  -  ^  -
E         + aabb.ccdd.eeff
E         ?     ^    ^

test_mac_address.py:6: AssertionError
================================================= short test summary info ============================================
FAILED test_mac_address.py::test_normalize_mac_address - AssertionError: assert 'aabb.ccdd.eeff' == 'aa:bb:cc:dd:ee:ff'

The last line gives a clear picture of why the assertion failed. This is what we expected as we didn’t do anything to the MAC address in the normalize_mac_address function. Let’s update that function and run the test again. Here is the updated function:

>>> def normalize_mac_address(mac):
>>>     if mac.count(".") == 2:
>>>         mac = f"{mac[0:2]}:{mac[2:4]}:{mac[5:7]}:{mac[7:9]}:{mac[10:12]}:{mac[12:14]}"
>>>     return mac

Now we get this output when we run pytest.

======================================== test session starts =====================================================
platform linux -- Python 3.8.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /home/adam/.virtualenvs/pytest/bin/python
cachedir: .pytest_cache
rootdir: /home/adam/repo/Sandbox/blog_pytest
collected 1 item

test_mac_address.py::test_normalize_mac_address  PASSED                                                     [100%]

======================================== 1 passed in 0.01s =======================================================

Our test has passed!


Conclusion

Unit testing, and testing in general, is a very important part of the network automation journey. I hope this blog post has shed some light on what pytest is and the basic functionality of it. As the example is, there are only two types of MAC addresses. What would happen if we passed in a MAC address in the format aabbcc-ddeeff to our function? The test would fail.

In my next blog post I’ll dive into expanding on this example to use parameterization and other features of pytest. Thanks for reading!

-Adam



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!