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
Tags :
Contact Us to Learn More
Share details about yourself & someone from our team will reach out to you ASAP!