Parametrised tests are a great help to create many test cases without the need to write many tests. Let us look how they work in pytest.
This post is part of my journey to learn Python. You can find the other parts of this series here.
FizzBuzz
I like the FizzBuzz kata for its minimalistic requirements. This helps us to focus on the task at hand and not on the business logic. The basic requirements are these:
Write a program that prints the numbers from 1 to 100. But for multiples of three print “Fizz” instead of the number and for the multiples of five print “Buzz”. For numbers which are multiples of both three and five print “FizzBuzz”.
I wrote this function to fulfil the requirements:
1 2 3 4 5 6 7 8 9 |
def fizz_buzz(value): if (value % 3 == 0 and value % 5 == 0): return "FizzBuzz" elif (value % 3 == 0): return "Fizz" elif (value % 5 == 0): return "Buzz" else: return value |
How not to test FizzBuzz
With all the parts I so far explained of pytest, we could come up with a lot of test functions like these ones:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
def test_1_returns_1(): assert 1 == fizz_buzz(1) def test_2_returns_2(): assert 2 == fizz_buzz(2) def test_3_return_Fizz(): assert "Fizz" == fizz_buzz(3) def test_4_returns_4(): assert 4 == fizz_buzz(4) def test_5_returns_Buzz(): assert "Buzz" == fizz_buzz(5) def test_6_return_Fizz(): assert "Fizz" == fizz_buzz(6) |
This is too much to write and if we change the fizz_buzz() function, we need to update all our tests. Considering these downsides, we may try to write something like this condensed test:
1 2 3 4 5 6 7 8 9 |
def test_multiples_of_3_return_Fizz(): assert "Fizz" == fizz_buzz(3) assert "Fizz" == fizz_buzz(6) assert "Fizz" == fizz_buzz(9) def test_multiples_of_5_return_Buzz(): assert "Buzz" == fizz_buzz(5) assert "Buzz" == fizz_buzz(10) assert "Buzz" == fizz_buzz(20) |
These tests have multiple asserts for different test cases. As soon as the first test fails, pytest stops the test function and we do not get any feedback about other errors. As long as everything works this kind of tests may be useful. But as soon as this is no longer the case, we have a big problem and need a lot of time to figure out what went wrong.
Parametrised tests to the rescue
The concept of parametrised tests helps us to solve our problem in an elegant and maintainable way. Instead of pushing multiple test cases together, we use them as parameters to the test function. To do that, we need the @pytest.mark.parametrize() decorator with these two values:
- The name of the parameters separated by ,
- A list of values (or tuples, if you have multiple parameters)
Using parametrised tests we can test fizz_buzz() for values from 1 to 50 with just 4 test functions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import pytest @pytest.mark.parametrize("input", [3,6,9,12,18,21,24,27,33,36,39,42,48]) def test_multiples_of_3_return_Fizz(input): assert "Fizz" == fizz_buzz(input) @pytest.mark.parametrize("input", [5,10,20,25,35,40,50]) def test_multiples_of_5_return_Buzz(input): assert "Buzz" == fizz_buzz(input) @pytest.mark.parametrize("input", [15,30,45]) def test_multiples_of_3_and_5_return_FizzBuzz(input): assert "FizzBuzz" == fizz_buzz(input) @pytest.mark.parametrize("input", [1,2,4,7,8,11,13,14,16,17,19,22,23,26,28,29,31,32,34,37,38,41,43,44,46,47,49]) def test_otherwise_returns_input(input): assert input == fizz_buzz(input) |
Pytest translates these 4 functions into 50 test cases:
1 |
$ pytest .\test_fizzbuzz.py |
=========== test session starts ============
platform win32 — Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: D:\Python, inifile: pytest.ini
plugins: cov-2.10.1, html-2.1.1, metadata-1.10.0
collected 50 itemstest_fizzbuzz.py ………………………………………….. [100%]
============ 50 passed in 0.12s ============
If one of those parametrised tests fails, pytest will report us exactly which one has a problem and continues to run all other tests. If I add 3 to the list of values that should return the number, I get this error message:
================= FAILURES =================
_____ test_otherwise_returns_input[3] ______input = 3
@pytest.mark.parametrize(“input”, [1,2,3,4,])
def test_otherwise_returns_input(input):
> assert input == fizz_buzz(input)
E AssertionError: assert 3 == ‘Fizz’
E + where ‘Fizz’ = fizz_buzz(3)test_fizzbuzz.py:62: AssertionError
This means that the input 3 failed and that fizz_buzz(3) returned ‘Fizz’ instead the expected 3. It is not the most understandable output, but you get used to it.
Multiple parameters for your test function
To use multiple parameters in our tests, we need to replace the list of values with a list of tuples – and make sure that they are in the order we specify the parameter names (in the first argument):
1 2 3 4 5 6 7 8 9 10 |
@pytest.mark.parametrize("input, expected", [(1, 1), (2, 2), (3, "Fizz"), (4, 4), (5, "Buzz"), (15,"FizzBuzz")]) def test_multiple_inputs(input, expected): assert expected == fizz_buzz(input) print(f"{input}: {expected}") |
If we spell a parameter wrong (like ‘expeted’ instead of expected), pytest will throw us an exception like this one:
1 |
$ pytest .\test_fizzbuzz.py |
=========== test session starts ============
platform win32 — Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: D:\Python, inifile: pytest.ini
plugins: cov-2.10.1, html-2.1.1, metadata-1.10.0
collected 0 items / 1 error================== ERRORS ==================
_________ ERROR collecting test_fizzbuzz.py __________
In test_multiple_inputs: function uses no argument ‘expeted’
========= short test summary info ==========
ERROR test_fizzbuzz.py
This safety net is a great help and prevents you from hours of debugging your tests.
Next
With parametrised tests we can save ourselves a lot of typing. But how do we know what parts of our application are covered by tests? Next week we will look at code coverage to answer this question.
1 thought on “Python Friday #51: Parametrised Tests With Pytest”